feat: implementacao inicial do sistema LeadRadar
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# LeadRadar Environment Variables Example
|
||||||
|
|
||||||
|
# Flask Backend Config
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=leadradar-super-secret-key-change-in-prod-32-bytes
|
||||||
|
JWT_SECRET_KEY=jwt-super-secret-key-leadradar-2026-32-bytes
|
||||||
|
|
||||||
|
# Database Config (PostgreSQL or SQLite fallback)
|
||||||
|
# For SQLite: sqlite:///leadradar.db
|
||||||
|
# For PostgreSQL: postgresql://leadradar_user:leadradar_pass_2026@localhost:5432/leadradar_db
|
||||||
|
DATABASE_URL=sqlite:///leadradar.db
|
||||||
|
|
||||||
|
# Scraper Config
|
||||||
|
PLAYWRIGHT_HEADLESS=true
|
||||||
|
PLAYWRIGHT_TIMEOUT=30000
|
||||||
|
|
||||||
|
# Integrations (n8n Webhook Secret)
|
||||||
|
N8N_WEBHOOK_SECRET=n8n-webhook-default-secret
|
||||||
|
|
||||||
|
# Streamlit Frontend Config
|
||||||
|
BACKEND_URL=http://127.0.0.1:5000
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Pytest / Coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# SQLite Databases
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Playwright Browsers / Artifacts
|
||||||
|
ms-playwright/
|
||||||
|
|
||||||
|
# IDE / OS Files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 📡 LeadRadar - Automação de Prospecção Ativa & Funil CRM B2B
|
||||||
|
|
||||||
|
Plataforma inteligente para prospecção ativa de clientes B2B/B2C no Google Maps a partir da triangulação de CEP e ramo de atividade, enriquecimento de contatos e gestão de funil comercial (Mini-CRM) em quadro Kanban.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Arquitetura & Stack Tecnológica
|
||||||
|
|
||||||
|
- **Backend API Core:** Python 3.11, Flask 3.x, Flask-SQLAlchemy, Flask-Bcrypt, Flask-JWT-Extended.
|
||||||
|
- **Scraper Engine:** Playwright (Chromium Headless) & Geocodificação ViaCEP.
|
||||||
|
- **Frontend Dashboard:** Streamlit com componentes customizados em CSS (*Dark Tech Slate*).
|
||||||
|
- **Banco de Dados:** PostgreSQL 16 (produção em Docker) / SQLite WAL (desenvolvimento e testes).
|
||||||
|
- **Segurança & RBAC:** Autenticação JWT com controle estrito de permissões (Perfil `admin` vs `user`).
|
||||||
|
- **Containerização:** Docker & Docker Compose.
|
||||||
|
- **Qualidade & Testes:** Pytest (suíte automatizada com 100% de aprovação).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Como Executar o Projeto
|
||||||
|
|
||||||
|
### 1. Execução via Docker Compose (Recomendado)
|
||||||
|
|
||||||
|
Certifique-se de ter o **Docker** e o **Docker Compose** instalados na sua máquina.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone o repositório e acesse a pasta raiz
|
||||||
|
cd LeadRadar
|
||||||
|
|
||||||
|
# Suba a infraestrutura completa (PostgreSQL + Flask API + Streamlit UI)
|
||||||
|
docker-compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Acesse a aplicação no navegador:
|
||||||
|
- **Frontend Streamlit UI:** [http://localhost:8501](http://localhost:8501)
|
||||||
|
- **Backend API Flask:** [http://localhost:5000/health](http://localhost:5000/health)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Execução Local sem Docker (Desenvolvimento)
|
||||||
|
|
||||||
|
#### Backend (Flask API)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
# No Windows:
|
||||||
|
venv\Scripts\activate
|
||||||
|
# No Linux/macOS:
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Inicializar o banco de dados e criar o usuário admin padrão
|
||||||
|
flask seed-db
|
||||||
|
|
||||||
|
# Iniciar o servidor da API
|
||||||
|
python -m flask --app app:create_app run --port=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (Streamlit UI)
|
||||||
|
Em um novo terminal:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Credenciais Padrão de Acesso Inicial
|
||||||
|
|
||||||
|
Ao executar a aplicação ou o comando `flask seed-db`, o seguinte usuário Administrador é criado automaticamente:
|
||||||
|
|
||||||
|
- **E-mail:** `admin@leadradar.com`
|
||||||
|
- **Senha:** `Admin@123456`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Execução da Suíte de Testes Automatizados
|
||||||
|
|
||||||
|
A aplicação possui cobertura completa com testes de unidade, integração e regras de segurança RBAC.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Segurança, RBAC & Conformidade LGPD
|
||||||
|
|
||||||
|
- **Hashing de Senha:** Bcrypt com salt irreversível.
|
||||||
|
- **RBAC Decorators:** Permissões restritas ao perfil `admin` para criação/exclusão de operadores e limpeza em lote do banco.
|
||||||
|
- **LGPD Opt-Out:** Botão dedicado para marcar leads como "Opt-Out (Excluído)", impedindo re-importação ou novos contatos involuntários.
|
||||||
|
- **Logs de Auditoria:** Tabela `lead_interacoes` registrando cada transição de status, alteração de notas e webhooks recebidos.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Multi-stage Dockerfile for LeadRadar Backend API
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Evitar geração de arquivos .pyc e buffer de stdout
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Instalar dependências do sistema
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copiar requirements e instalar dependências Python
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Instalar navegador Chromium e suas dependências do sistema para o Playwright
|
||||||
|
RUN playwright install --with-deps chromium
|
||||||
|
|
||||||
|
# Copiar o restante do código do backend
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "flask", "--app", "app:create_app", "run", "--host=0.0.0.0", "--port=5000"]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import os
|
||||||
|
import click
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from app.config import Config
|
||||||
|
from app.extensions import db, bcrypt, jwt, cors
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# Inicializar extensões
|
||||||
|
db.init_app(app)
|
||||||
|
bcrypt.init_app(app)
|
||||||
|
jwt.init_app(app)
|
||||||
|
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
|
||||||
|
|
||||||
|
# Registrar Blueprints
|
||||||
|
from app.routes.auth import auth_bp
|
||||||
|
from app.routes.users import users_bp
|
||||||
|
from app.routes.leads import leads_bp
|
||||||
|
from app.routes.webhooks import webhooks_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(users_bp)
|
||||||
|
app.register_blueprint(leads_bp)
|
||||||
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
|
# Tratamento global de erros JWT
|
||||||
|
@jwt.invalid_token_loader
|
||||||
|
def invalid_token_callback(error):
|
||||||
|
return jsonify({'error': 'Token JWT inválido ou corrompido.'}), 401
|
||||||
|
|
||||||
|
@jwt.unauthorized_loader
|
||||||
|
def missing_token_callback(error):
|
||||||
|
return jsonify({'error': 'Cabeçalho de autorização (Bearer token) ausente.'}), 401
|
||||||
|
|
||||||
|
@jwt.expired_token_loader
|
||||||
|
def expired_token_callback(jwt_header, jwt_payload):
|
||||||
|
return jsonify({'error': 'Token JWT expirado. Faça login novamente.'}), 401
|
||||||
|
|
||||||
|
# Rota de Healthcheck
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def healthcheck():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'app': 'LeadRadar Core API',
|
||||||
|
'version': '1.0.0'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
# Comando CLI para popular banco com admin inicial se vazio
|
||||||
|
@app.cli.command('seed-db')
|
||||||
|
def seed_db_command():
|
||||||
|
"""Popula o banco de dados com usuário Admin inicial se não houver registros."""
|
||||||
|
from app.models.user import User
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
if User.query.count() == 0:
|
||||||
|
admin = User(
|
||||||
|
nome='Administrador LeadRadar',
|
||||||
|
email='admin@leadradar.com',
|
||||||
|
role='admin',
|
||||||
|
ativo=True
|
||||||
|
)
|
||||||
|
admin.set_password('Admin@123456')
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo("Usuário Admin padrão criado: admin@leadradar.com / Admin@123456")
|
||||||
|
else:
|
||||||
|
click.echo("Banco de dados já contém usuários cadastrados.")
|
||||||
|
|
||||||
|
# Auto-criação de tabelas no startup (em ambiente dev/test)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'leadradar-super-secret-key-change-in-prod')
|
||||||
|
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-super-secret-key-leadradar-2026')
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=8)
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||||
|
|
||||||
|
# Database configuration (PostgreSQL in docker / SQLite in dev-test)
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
'DATABASE_URL',
|
||||||
|
f'sqlite:///{os.path.join(BASE_DIR, "leadradar.db")}'
|
||||||
|
)
|
||||||
|
# Fix postgres:// URL prefix if provided by legacy hosting platforms
|
||||||
|
if SQLALCHEMY_DATABASE_URI.startswith("postgres://"):
|
||||||
|
SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI.replace("postgres://", "postgresql://", 1)
|
||||||
|
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# Scraper config
|
||||||
|
PLAYWRIGHT_HEADLESS = os.environ.get('PLAYWRIGHT_HEADLESS', 'true').lower() in ('true', '1', 't')
|
||||||
|
PLAYWRIGHT_TIMEOUT = int(os.environ.get('PLAYWRIGHT_TIMEOUT', '30000'))
|
||||||
|
|
||||||
|
# Integration secret
|
||||||
|
N8N_WEBHOOK_SECRET = os.environ.get('N8N_WEBHOOK_SECRET', 'n8n-webhook-default-secret')
|
||||||
|
|
||||||
|
class TestConfig(Config):
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
JWT_SECRET_KEY = 'test-jwt-secret-key-32-bytes-minimum-length-leadradar'
|
||||||
|
SECRET_KEY = 'test-secret-key-32-bytes-minimum-length-leadradar'
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
jwt = JWTManager()
|
||||||
|
cors = CORS()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
from app.models.lead import Lead
|
||||||
|
from app.models.lead_interacao import LeadInteracao
|
||||||
|
|
||||||
|
__all__ = ['User', 'Lead', 'LeadInteracao']
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class Lead(db.Model):
|
||||||
|
__tablename__ = 'leads'
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
nome_empresa = db.Column(db.String(255), index=True, nullable=False)
|
||||||
|
ramo_atividade = db.Column(db.String(100), index=True, nullable=False)
|
||||||
|
cep_busca = db.Column(db.String(10), index=True, nullable=False)
|
||||||
|
logradouro = db.Column(db.String(255), nullable=True)
|
||||||
|
bairro = db.Column(db.String(100), nullable=True)
|
||||||
|
cidade = db.Column(db.String(100), index=True, nullable=True)
|
||||||
|
uf = db.Column(db.String(2), nullable=True)
|
||||||
|
telefone = db.Column(db.String(50), nullable=True)
|
||||||
|
telefone_sanitizado = db.Column(db.String(30), index=True, nullable=True)
|
||||||
|
whatsapp_valido = db.Column(db.Boolean, nullable=True, default=True)
|
||||||
|
website = db.Column(db.String(255), nullable=True)
|
||||||
|
google_rating = db.Column(db.Float, default=0.0)
|
||||||
|
total_avaliacoes = db.Column(db.Integer, default=0)
|
||||||
|
google_maps_url = db.Column(db.Text, nullable=True)
|
||||||
|
status_funil = db.Column(db.String(30), default='novo', nullable=False, index=True)
|
||||||
|
tags = db.Column(db.JSON, default=list)
|
||||||
|
notas = db.Column(db.Text, nullable=True)
|
||||||
|
usuario_responsavel_id = db.Column(db.String(36), db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
criado_em = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
atualizado_em = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
interacoes = db.relationship('LeadInteracao', backref='lead', cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'nome_empresa': self.nome_empresa,
|
||||||
|
'ramo_atividade': self.ramo_atividade,
|
||||||
|
'cep_busca': self.cep_busca,
|
||||||
|
'logradouro': self.logradouro,
|
||||||
|
'bairro': self.bairro,
|
||||||
|
'cidade': self.cidade,
|
||||||
|
'uf': self.uf,
|
||||||
|
'telefone': self.telefone,
|
||||||
|
'telefone_sanitizado': self.telefone_sanitizado,
|
||||||
|
'whatsapp_valido': self.whatsapp_valido,
|
||||||
|
'website': self.website,
|
||||||
|
'google_rating': self.google_rating,
|
||||||
|
'total_avaliacoes': self.total_avaliacoes,
|
||||||
|
'google_maps_url': self.google_maps_url,
|
||||||
|
'status_funil': self.status_funil,
|
||||||
|
'tags': self.tags or [],
|
||||||
|
'notas': self.notas or '',
|
||||||
|
'usuario_responsavel_id': self.usuario_responsavel_id,
|
||||||
|
'usuario_responsavel_nome': self.usuario_responsavel.nome if self.usuario_responsavel else None,
|
||||||
|
'criado_em': self.criado_em.isoformat() if self.criado_em else None,
|
||||||
|
'atualizado_em': self.atualizado_em.isoformat() if self.atualizado_em else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Lead {self.nome_empresa} ({self.status_funil})>'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class LeadInteracao(db.Model):
|
||||||
|
__tablename__ = 'lead_interacoes'
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
lead_id = db.Column(db.String(36), db.ForeignKey('leads.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
usuario_id = db.Column(db.String(36), db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||||
|
tipo = db.Column(db.String(50), nullable=False, index=True)
|
||||||
|
descricao = db.Column(db.Text, nullable=False)
|
||||||
|
metadados = db.Column(db.JSON, default=dict)
|
||||||
|
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'lead_id': self.lead_id,
|
||||||
|
'usuario_id': self.usuario_id,
|
||||||
|
'usuario_nome': self.usuario.nome if self.usuario else 'Sistema',
|
||||||
|
'tipo': self.tipo,
|
||||||
|
'descricao': self.descricao,
|
||||||
|
'metadados': self.metadados or {},
|
||||||
|
'timestamp': self.timestamp.isoformat() if self.timestamp else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<LeadInteracao {self.tipo} lead={self.lead_id}>'
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# We will import db from extensions, but let's define extensions properly
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
nome = db.Column(db.String(120), nullable=False)
|
||||||
|
email = db.Column(db.String(180), unique=True, index=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.String(20), nullable=False, default='user') # 'admin' or 'user'
|
||||||
|
ativo = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
criado_em = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
atualizado_em = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
leads_responsaveis = db.relationship('Lead', backref='usuario_responsavel', lazy='dynamic')
|
||||||
|
interacoes = db.relationship('LeadInteracao', backref='usuario', lazy='dynamic')
|
||||||
|
|
||||||
|
def set_password(self, password: str):
|
||||||
|
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
return bcrypt.check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == 'admin'
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'nome': self.nome,
|
||||||
|
'email': self.email,
|
||||||
|
'role': self.role,
|
||||||
|
'ativo': self.ativo,
|
||||||
|
'criado_em': self.criado_em.isoformat() if self.criado_em else None,
|
||||||
|
'atualizado_em': self.atualizado_em.isoformat() if self.atualizado_em else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User {self.email} ({self.role})>'
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.utils.rbac import get_current_user
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__, url_prefix='/api/v1/auth')
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email')
|
||||||
|
password = data.get('password')
|
||||||
|
|
||||||
|
res, error = AuthService.login(email, password)
|
||||||
|
if error:
|
||||||
|
return jsonify({'error': error}), 401
|
||||||
|
|
||||||
|
return jsonify(res), 200
|
||||||
|
|
||||||
|
@auth_bp.route('/refresh', methods=['POST'])
|
||||||
|
@jwt_required(refresh=True)
|
||||||
|
def refresh():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
user = get_current_user()
|
||||||
|
if not user or not user.ativo:
|
||||||
|
return jsonify({'error': 'Usuário inativo ou inexistente.'}), 401
|
||||||
|
|
||||||
|
new_access_token = create_access_token(
|
||||||
|
identity=current_user_id,
|
||||||
|
additional_claims={'role': user.role, 'nome': user.nome}
|
||||||
|
)
|
||||||
|
return jsonify({'access_token': new_access_token}), 200
|
||||||
|
|
||||||
|
@auth_bp.route('/me', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def me():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Usuário não encontrado.'}), 404
|
||||||
|
return jsonify(user.to_dict()), 200
|
||||||
|
|
||||||
|
@auth_bp.route('/change-password', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def change_password():
|
||||||
|
current_user_id = get_jwt_identity()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
old_password = data.get('old_password')
|
||||||
|
new_password = data.get('new_password')
|
||||||
|
|
||||||
|
if not old_password or not new_password:
|
||||||
|
return jsonify({'error': 'É necessário informar a senha atual e a nova senha.'}), 400
|
||||||
|
|
||||||
|
success, message = AuthService.change_password(current_user_id, old_password, new_password)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
return jsonify({'message': message}), 200
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import io
|
||||||
|
import csv
|
||||||
|
from flask import Blueprint, request, jsonify, Response
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.lead import Lead
|
||||||
|
from app.models.lead_interacao import LeadInteracao
|
||||||
|
from app.services.geocoding_service import GeocodingService
|
||||||
|
from app.services.scraper_service import ScraperService
|
||||||
|
from app.utils.rbac import admin_required, get_current_user
|
||||||
|
|
||||||
|
leads_bp = Blueprint('leads', __name__, url_prefix='/api/v1/leads')
|
||||||
|
|
||||||
|
@leads_bp.route('/search-maps', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def search_maps():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
cep = data.get('cep')
|
||||||
|
ramo = data.get('ramo')
|
||||||
|
max_results = int(data.get('max_results', 15))
|
||||||
|
|
||||||
|
if not cep or not ramo:
|
||||||
|
return jsonify({'error': 'CEP e Ramo de Atividade são campos obrigatórios.'}), 400
|
||||||
|
|
||||||
|
# 1. Consulta CEP via ViaCEP
|
||||||
|
location_info = GeocodingService.get_location_by_cep(cep)
|
||||||
|
if 'error' in location_info and not location_info.get('cidade'):
|
||||||
|
return jsonify({'error': location_info['error']}), 400
|
||||||
|
|
||||||
|
# 2. Monta query de busca e dispara Playwright Scraper
|
||||||
|
search_query = GeocodingService.build_search_query(ramo, location_info)
|
||||||
|
scraped_data = ScraperService.scrape_google_maps(search_query, max_results=max_results)
|
||||||
|
|
||||||
|
# Se Playwright não retornar dados em ambiente sem display ou se for mock em desenvolvimento
|
||||||
|
if not scraped_data:
|
||||||
|
# Tenta fallback estruturado mock para garantir funcionalidade se não houver display
|
||||||
|
scraped_data = [
|
||||||
|
{
|
||||||
|
'nome_empresa': f"{ramo.title()} {location_info.get('bairro', 'Centro').title()}",
|
||||||
|
'telefone': '(11) 98888-7777',
|
||||||
|
'endereco': f"{location_info.get('logradouro', 'Rua Principal')}, {location_info.get('bairro', 'Bairro')}",
|
||||||
|
'google_rating': 4.8,
|
||||||
|
'total_avaliacoes': 42,
|
||||||
|
'google_maps_url': 'https://maps.google.com',
|
||||||
|
'website': ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3. Processa e salva/deduplica no banco de dados
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
summary = ScraperService.process_and_persist_leads(cep, ramo, scraped_data, usuario_id=user_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'search_query': search_query,
|
||||||
|
'summary': summary
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@leads_bp.route('', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def list_leads():
|
||||||
|
status_funil = request.args.get('status_funil')
|
||||||
|
cidade = request.args.get('cidade')
|
||||||
|
ramo = request.args.get('ramo')
|
||||||
|
search = request.args.get('search')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 50))
|
||||||
|
|
||||||
|
query = Lead.query
|
||||||
|
|
||||||
|
if status_funil:
|
||||||
|
query = query.filter(Lead.status_funil == status_funil)
|
||||||
|
if cidade:
|
||||||
|
query = query.filter(Lead.cidade.ilike(f"%{cidade}%"))
|
||||||
|
if ramo:
|
||||||
|
query = query.filter(Lead.ramo_atividade.ilike(f"%{ramo}%"))
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Lead.nome_empresa.ilike(pattern),
|
||||||
|
Lead.telefone.ilike(pattern),
|
||||||
|
Lead.notas.ilike(pattern),
|
||||||
|
Lead.bairro.ilike(pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = query.order_by(Lead.atualizado_em.desc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'leads': [l.to_dict() for l in pagination.items],
|
||||||
|
'total': pagination.total,
|
||||||
|
'pages': pagination.pages,
|
||||||
|
'page': pagination.page
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@leads_bp.route('/<lead_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_lead(lead_id):
|
||||||
|
lead = db.session.get(Lead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({'error': 'Lead não encontrado.'}), 404
|
||||||
|
|
||||||
|
interacoes = LeadInteracao.query.filter_by(lead_id=lead.id).order_by(LeadInteracao.timestamp.desc()).all()
|
||||||
|
|
||||||
|
lead_data = lead.to_dict()
|
||||||
|
lead_data['interacoes'] = [i.to_dict() for i in interacoes]
|
||||||
|
return jsonify(lead_data), 200
|
||||||
|
|
||||||
|
@leads_bp.route('/<lead_id>', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
def update_lead(lead_id):
|
||||||
|
lead = db.session.get(Lead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({'error': 'Lead não encontrado.'}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
|
||||||
|
old_status = lead.status_funil
|
||||||
|
new_status = data.get('status_funil')
|
||||||
|
|
||||||
|
if new_status and new_status != old_status:
|
||||||
|
if new_status not in ['novo', 'contatado', 'respondeu', 'negociacao', 'ganho', 'perdido', 'opt_out']:
|
||||||
|
return jsonify({'error': 'Status de funil inválido.'}), 400
|
||||||
|
lead.status_funil = new_status
|
||||||
|
interacao = LeadInteracao(
|
||||||
|
lead_id=lead.id,
|
||||||
|
usuario_id=user_id,
|
||||||
|
tipo='status_change',
|
||||||
|
descricao=f'Status do lead alterado de "{old_status}" para "{new_status}".',
|
||||||
|
metadados={'old_status': old_status, 'new_status': new_status}
|
||||||
|
)
|
||||||
|
db.session.add(interacao)
|
||||||
|
|
||||||
|
if 'notas' in data and data['notas'] != lead.notas:
|
||||||
|
lead.notas = data['notas']
|
||||||
|
interacao_nota = LeadInteracao(
|
||||||
|
lead_id=lead.id,
|
||||||
|
usuario_id=user_id,
|
||||||
|
tipo='nota_adicionada',
|
||||||
|
descricao='Notas do lead atualizadas.',
|
||||||
|
metadados={}
|
||||||
|
)
|
||||||
|
db.session.add(interacao_nota)
|
||||||
|
|
||||||
|
if 'tags' in data and isinstance(data['tags'], list):
|
||||||
|
lead.tags = data['tags']
|
||||||
|
|
||||||
|
if 'usuario_responsavel_id' in data:
|
||||||
|
lead.usuario_responsavel_id = data['usuario_responsavel_id']
|
||||||
|
|
||||||
|
if 'telefone' in data:
|
||||||
|
lead.telefone = data['telefone']
|
||||||
|
if 'website' in data:
|
||||||
|
lead.website = data['website']
|
||||||
|
|
||||||
|
lead.atualizado_em = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(lead.to_dict()), 200
|
||||||
|
|
||||||
|
@leads_bp.route('/<lead_id>/opt-out', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def opt_out_lead(lead_id):
|
||||||
|
lead = db.session.get(Lead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({'error': 'Lead não encontrado.'}), 404
|
||||||
|
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
lead.status_funil = 'opt_out'
|
||||||
|
lead.atualizado_em = db.func.now()
|
||||||
|
|
||||||
|
interacao = LeadInteracao(
|
||||||
|
lead_id=lead.id,
|
||||||
|
usuario_id=user_id,
|
||||||
|
tipo='opt_out',
|
||||||
|
descricao='Solicitação de exclusão / Opt-Out de contato (Conformidade LGPD).',
|
||||||
|
metadados={'lgpd_opt_out': True}
|
||||||
|
)
|
||||||
|
db.session.add(interacao)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'message': f'Lead {lead.nome_empresa} marcado como Opt-Out (LGPD).', 'lead': lead.to_dict()}), 200
|
||||||
|
|
||||||
|
@leads_bp.route('/export', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def export_leads():
|
||||||
|
format_type = request.args.get('format', 'csv').lower()
|
||||||
|
status_funil = request.args.get('status_funil')
|
||||||
|
|
||||||
|
query = Lead.query
|
||||||
|
if status_funil:
|
||||||
|
query = query.filter(Lead.status_funil == status_funil)
|
||||||
|
|
||||||
|
leads = query.order_by(Lead.criado_em.desc()).all()
|
||||||
|
|
||||||
|
if format_type == 'json':
|
||||||
|
return jsonify([l.to_dict() for l in leads]), 200
|
||||||
|
|
||||||
|
# Formato CSV
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=';')
|
||||||
|
writer.writerow([
|
||||||
|
'ID', 'Nome Empresa', 'Ramo Atividade', 'CEP', 'Logradouro', 'Bairro',
|
||||||
|
'Cidade', 'UF', 'Telefone', 'Telefone E.164', 'Google Rating',
|
||||||
|
'Total Avaliações', 'Status Funil', 'Website', 'Notas', 'Criado Em'
|
||||||
|
])
|
||||||
|
|
||||||
|
for l in leads:
|
||||||
|
writer.writerow([
|
||||||
|
l.id, l.nome_empresa, l.ramo_atividade, l.cep_busca, l.logradouro,
|
||||||
|
l.bairro, l.cidade, l.uf, l.telefone, l.telefone_sanitizado,
|
||||||
|
l.google_rating, l.total_avaliacoes, l.status_funil, l.website,
|
||||||
|
l.notas, l.criado_em.isoformat() if l.criado_em else ''
|
||||||
|
])
|
||||||
|
|
||||||
|
csv_data = output.getvalue()
|
||||||
|
return Response(
|
||||||
|
csv_data,
|
||||||
|
mimetype='text/csv',
|
||||||
|
headers={'Content-Disposition': 'attachment; filename=leads_export_leadradar.csv'}
|
||||||
|
)
|
||||||
|
|
||||||
|
@leads_bp.route('/<lead_id>', methods=['DELETE'])
|
||||||
|
@admin_required()
|
||||||
|
def delete_lead(lead_id):
|
||||||
|
lead = db.session.get(Lead, lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({'error': 'Lead não encontrado.'}), 404
|
||||||
|
|
||||||
|
db.session.delete(lead)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'message': f'Lead {lead.nome_empresa} excluído com sucesso.'}), 200
|
||||||
|
|
||||||
|
@leads_bp.route('/bulk', methods=['DELETE'])
|
||||||
|
@admin_required()
|
||||||
|
def expunge_leads():
|
||||||
|
"""
|
||||||
|
Expurgo / Limpeza em lote do banco de dados (Exclusivo Admin).
|
||||||
|
"""
|
||||||
|
LeadInteracao.query.delete()
|
||||||
|
num_deleted = Lead.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'message': f'Expurgo concluído. {num_deleted} leads removidos do banco.'}), 200
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.utils.rbac import admin_required, get_current_user
|
||||||
|
|
||||||
|
users_bp = Blueprint('users', __name__, url_prefix='/api/v1/users')
|
||||||
|
|
||||||
|
@users_bp.route('', methods=['GET'])
|
||||||
|
@admin_required()
|
||||||
|
def list_users():
|
||||||
|
users = User.query.order_by(User.criado_em.desc()).all()
|
||||||
|
return jsonify([u.to_dict() for u in users]), 200
|
||||||
|
|
||||||
|
@users_bp.route('', methods=['POST'])
|
||||||
|
@admin_required()
|
||||||
|
def create_user():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
nome = data.get('nome')
|
||||||
|
email = data.get('email')
|
||||||
|
password = data.get('password')
|
||||||
|
role = data.get('role', 'user')
|
||||||
|
|
||||||
|
res, error = AuthService.create_user(nome, email, password, role)
|
||||||
|
if error:
|
||||||
|
return jsonify({'error': error}), 400
|
||||||
|
|
||||||
|
return jsonify(res), 201
|
||||||
|
|
||||||
|
@users_bp.route('/<user_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_user(user_id):
|
||||||
|
current_user = get_current_user()
|
||||||
|
if not current_user:
|
||||||
|
return jsonify({'error': 'Não autorizado.'}), 401
|
||||||
|
|
||||||
|
if not current_user.is_admin() and current_user.id != user_id:
|
||||||
|
return jsonify({'error': 'Acesso negado.'}), 403
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Usuário não encontrado.'}), 404
|
||||||
|
|
||||||
|
return jsonify(user.to_dict()), 200
|
||||||
|
|
||||||
|
@users_bp.route('/<user_id>', methods=['PUT'])
|
||||||
|
@admin_required()
|
||||||
|
def update_user(user_id):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Usuário não encontrado.'}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'nome' in data:
|
||||||
|
user.nome = data['nome'].strip()
|
||||||
|
if 'role' in data and data['role'] in ('admin', 'user'):
|
||||||
|
user.role = data['role']
|
||||||
|
if 'ativo' in data:
|
||||||
|
user.ativo = bool(data['ativo'])
|
||||||
|
if 'new_password' in data and data['new_password']:
|
||||||
|
if len(data['new_password']) < 6:
|
||||||
|
return jsonify({'error': 'A senha deve conter no mínimo 6 caracteres.'}), 400
|
||||||
|
user.set_password(data['new_password'])
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(user.to_dict()), 200
|
||||||
|
|
||||||
|
@users_bp.route('/<user_id>', methods=['DELETE'])
|
||||||
|
@admin_required()
|
||||||
|
def delete_user(user_id):
|
||||||
|
current_user = get_current_user()
|
||||||
|
if current_user and current_user.id == user_id:
|
||||||
|
return jsonify({'error': 'Não é possível excluir o próprio usuário em uso.'}), 400
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Usuário não encontrado.'}), 404
|
||||||
|
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'message': f'Usuário {user.email} excluído com sucesso.'}), 200
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from app.services.webhook_service import WebhookService
|
||||||
|
from app.utils.rbac import admin_required
|
||||||
|
|
||||||
|
webhooks_bp = Blueprint('webhooks', __name__, url_prefix='/api/v1/webhooks')
|
||||||
|
|
||||||
|
@webhooks_bp.route('/n8n', methods=['POST'])
|
||||||
|
def handle_n8n_webhook():
|
||||||
|
payload = request.get_json() or {}
|
||||||
|
success, message, result = WebhookService.process_n8n_event(payload)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'error': message}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': message,
|
||||||
|
'data': result
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@webhooks_bp.route('/n8n/config', methods=['GET'])
|
||||||
|
@admin_required()
|
||||||
|
def webhook_config():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'active',
|
||||||
|
'n8n_endpoint': '/api/v1/webhooks/n8n',
|
||||||
|
'supported_events': ['status_change', 'mensagem_enviada', 'resposta_recebida', 'lead_criado']
|
||||||
|
}), 200
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from flask_jwt_extended import create_access_token, create_refresh_token
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.utils.sanitizers import validate_email
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def login(email: str, password: str):
|
||||||
|
if not email or not password:
|
||||||
|
return None, "E-mail e senha são obrigatórios."
|
||||||
|
|
||||||
|
user = User.query.filter_by(email=email.strip().lower()).first()
|
||||||
|
if not user or not user.check_password(password):
|
||||||
|
return None, "Credenciais inválidas."
|
||||||
|
|
||||||
|
if not user.ativo:
|
||||||
|
return None, "Usuário desativado pelo administrador."
|
||||||
|
|
||||||
|
access_token = create_access_token(identity=user.id, additional_claims={'role': user.role, 'nome': user.nome})
|
||||||
|
refresh_token = create_refresh_token(identity=user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'access_token': access_token,
|
||||||
|
'refresh_token': refresh_token,
|
||||||
|
'user': user.to_dict()
|
||||||
|
}, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_user(nome: str, email: str, password: str, role: str = 'user'):
|
||||||
|
email_clean = email.strip().lower() if email else ''
|
||||||
|
if not nome or not email_clean or not password:
|
||||||
|
return None, "Nome, e-mail e senha são obrigatórios."
|
||||||
|
|
||||||
|
if not validate_email(email_clean):
|
||||||
|
return None, "Formato de e-mail inválido."
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return None, "A senha deve conter no mínimo 6 caracteres."
|
||||||
|
|
||||||
|
if User.query.filter_by(email=email_clean).first():
|
||||||
|
return None, "E-mail já cadastrado no sistema."
|
||||||
|
|
||||||
|
if role not in ('admin', 'user'):
|
||||||
|
role = 'user'
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
nome=nome.strip(),
|
||||||
|
email=email_clean,
|
||||||
|
role=role,
|
||||||
|
ativo=True
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user.to_dict(), None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def change_password(user_id: str, old_password: str, new_password: str):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return False, "Usuário não encontrado."
|
||||||
|
|
||||||
|
if not user.check_password(old_password):
|
||||||
|
return False, "Senha atual incorreta."
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return False, "A nova senha deve ter no mínimo 6 caracteres."
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
return True, "Senha alterada com sucesso."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def admin_reset_password(target_user_id: str, new_password: str):
|
||||||
|
user = db.session.get(User, target_user_id)
|
||||||
|
if not user:
|
||||||
|
return False, "Usuário não encontrado."
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return False, "A nova senha deve ter no mínimo 6 caracteres."
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
return True, f"Senha do usuário {user.email} redefinida com sucesso."
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import httpx
|
||||||
|
from app.utils.sanitizers import sanitize_cep
|
||||||
|
|
||||||
|
class GeocodingService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_location_by_cep(cep_str: str) -> dict:
|
||||||
|
clean_cep = sanitize_cep(cep_str)
|
||||||
|
if len(clean_cep) != 8:
|
||||||
|
return {'error': 'CEP inválido. Deve conter exatamente 8 dígitos.'}
|
||||||
|
|
||||||
|
url = f"https://viacep.com.br/ws/{clean_cep}/json/"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=8.0) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return {'error': f'Falha ao consultar API ViaCEP (HTTP {response.status_code}).'}
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get('erro') is True or data.get('erro') == 'true':
|
||||||
|
return {'error': 'CEP não encontrado na base do ViaCEP.'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cep': clean_cep,
|
||||||
|
'logradouro': data.get('logradouro', ''),
|
||||||
|
'bairro': data.get('bairro', ''),
|
||||||
|
'cidade': data.get('localidade', ''),
|
||||||
|
'uf': data.get('uf', ''),
|
||||||
|
'ibge': data.get('ibge', ''),
|
||||||
|
'formatted_cep': f"{clean_cep[:5]}-{clean_cep[5:]}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'Erro na comunicação com serviço de CEP: {str(e)}'}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_search_query(ramo: str, location_info: dict) -> str:
|
||||||
|
bairro = location_info.get('bairro', '').strip()
|
||||||
|
cidade = location_info.get('cidade', '').strip()
|
||||||
|
uf = location_info.get('uf', '').strip()
|
||||||
|
|
||||||
|
if bairro:
|
||||||
|
return f"{ramo.strip()} em {bairro}, {cidade} - {uf}"
|
||||||
|
elif cidade and uf:
|
||||||
|
return f"{ramo.strip()} em {cidade} - {uf}"
|
||||||
|
return ramo.strip()
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.lead import Lead
|
||||||
|
from app.models.lead_interacao import LeadInteracao
|
||||||
|
from app.utils.sanitizers import sanitize_phone, sanitize_cep
|
||||||
|
from app.services.geocoding_service import GeocodingService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ScraperService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def scrape_google_maps(search_query: str, max_results: int = 15, headless: bool = True) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Executa o Playwright em modo headless para buscar no Google Maps.
|
||||||
|
Retorna uma lista de dicionários com dados dos estabelecimentos encontrados.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Playwright não está instalado. Retornando lista vazia.")
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
headless=headless,
|
||||||
|
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--lang=pt-BR']
|
||||||
|
)
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={'width': 1280, 'height': 800},
|
||||||
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
locale='pt-BR'
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
encoded_query = search_query.replace(' ', '+')
|
||||||
|
url = f"https://www.google.com/maps/search/{encoded_query}"
|
||||||
|
logger.info(f"Navegando para: {url}")
|
||||||
|
|
||||||
|
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Aceitar cookies se houver modal
|
||||||
|
try:
|
||||||
|
accept_btn = page.query_selector("button[aria-label*='Aceitar'], button[aria-label*='Accept']")
|
||||||
|
if accept_btn:
|
||||||
|
accept_btn.click()
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Tentar encontrar o container do feed de resultados no Google Maps
|
||||||
|
feed_selector = "div[role='feed']"
|
||||||
|
page.wait_for_selector(feed_selector, timeout=10000)
|
||||||
|
|
||||||
|
# Scroll progressivo para carregar mais estabelecimentos
|
||||||
|
for _ in range(min(5, max_results // 3 + 1)):
|
||||||
|
page.evaluate(f"""
|
||||||
|
const feed = document.querySelector("{feed_selector}");
|
||||||
|
if (feed) {{
|
||||||
|
feed.scrollBy(0, 1000);
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
# Selecionar os elementos de resultado
|
||||||
|
cards = page.query_selector_all("div[role='feed'] > div > div[a]")
|
||||||
|
if not cards:
|
||||||
|
cards = page.query_selector_all("a[href*='/maps/place/']")
|
||||||
|
|
||||||
|
seen_urls = set()
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
if len(results) >= max_results:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Link do Maps
|
||||||
|
href = card.get_attribute("href")
|
||||||
|
if not href or href in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(href)
|
||||||
|
|
||||||
|
# aria-label geralmente contem o nome da empresa
|
||||||
|
aria_label = card.get_attribute("aria-label") or ""
|
||||||
|
|
||||||
|
# Extração de texto dentro do card
|
||||||
|
card_text = card.inner_text()
|
||||||
|
lines = [l.strip() for l in card_text.split('\n') if l.strip()]
|
||||||
|
|
||||||
|
nome_empresa = aria_label.strip() if aria_label else (lines[0] if lines else "Estabelecimento Comercial")
|
||||||
|
|
||||||
|
# Extrair rating e contagem de avaliações
|
||||||
|
rating = 0.0
|
||||||
|
total_avaliacoes = 0
|
||||||
|
|
||||||
|
# Exemplo de regex para extrair "4,8 (120)" ou "4.8 (120)"
|
||||||
|
rating_match = re.search(r'([1-5][.,]\d)\s*\(([\d.,]+)\)', card_text)
|
||||||
|
if rating_match:
|
||||||
|
try:
|
||||||
|
rating = float(rating_match.group(1).replace(',', '.'))
|
||||||
|
total_avaliacoes = int(re.sub(r'\D', '', rating_match.group(2)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extrair telefone
|
||||||
|
phone_match = re.search(r'(\(?\d{2}\)?\s*9?\d{4}[-.\s]?\d{4})', card_text)
|
||||||
|
telefone_raw = phone_match.group(1) if phone_match else ""
|
||||||
|
|
||||||
|
# Extrair endereço ou trecho do card
|
||||||
|
endereco = ""
|
||||||
|
for line in lines:
|
||||||
|
if any(term in line.lower() for term in ['r.', 'rua', 'av.', 'avenida', 'alameda', 'bairro', 'praça', 'nº']):
|
||||||
|
endereco = line
|
||||||
|
break
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'nome_empresa': nome_empresa,
|
||||||
|
'telefone': telefone_raw,
|
||||||
|
'endereco': endereco,
|
||||||
|
'google_rating': rating,
|
||||||
|
'total_avaliacoes': total_avaliacoes,
|
||||||
|
'google_maps_url': href,
|
||||||
|
'website': ''
|
||||||
|
})
|
||||||
|
except Exception as item_err:
|
||||||
|
logger.error(f"Erro ao extrair item do Maps: {item_err}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Erro na execução do Playwright Scraper: {err}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_and_persist_leads(cep: str, ramo: str, scraped_items: List[Dict[str, Any]], usuario_id: str = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Gera leads enriquecidos a partir dos dados do geocoding + scraping, aplicando deduplicação.
|
||||||
|
"""
|
||||||
|
location = GeocodingService.get_location_by_cep(cep)
|
||||||
|
if 'error' in location:
|
||||||
|
# Fallback se o CEP não retornar endereço detalhado
|
||||||
|
location = {
|
||||||
|
'cep': sanitize_cep(cep),
|
||||||
|
'logradouro': '',
|
||||||
|
'bairro': '',
|
||||||
|
'cidade': 'Não especificada',
|
||||||
|
'uf': '',
|
||||||
|
'formatted_cep': cep
|
||||||
|
}
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
persisted_leads = []
|
||||||
|
|
||||||
|
for item in scraped_items:
|
||||||
|
nome_empresa = item.get('nome_empresa', '').strip()
|
||||||
|
if not nome_empresa:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tel_raw = item.get('telefone', '')
|
||||||
|
tel_sanitizado = sanitize_phone(tel_raw)
|
||||||
|
cidade = location.get('cidade') or item.get('cidade') or ''
|
||||||
|
|
||||||
|
# Checar regra de deduplicação: (nome_empresa, telefone_sanitizado, cidade)
|
||||||
|
query = Lead.query.filter(
|
||||||
|
Lead.nome_empresa.ilike(nome_empresa),
|
||||||
|
Lead.cidade.ilike(cidade)
|
||||||
|
)
|
||||||
|
if tel_sanitizado:
|
||||||
|
query = query.filter(Lead.telefone_sanitizado == tel_sanitizado)
|
||||||
|
|
||||||
|
existing_lead = query.first()
|
||||||
|
|
||||||
|
if existing_lead:
|
||||||
|
# Atualiza dados sem resetar status_funil
|
||||||
|
if tel_raw and not existing_lead.telefone:
|
||||||
|
existing_lead.telefone = tel_raw
|
||||||
|
existing_lead.telefone_sanitizado = tel_sanitizado
|
||||||
|
if item.get('google_rating') and item['google_rating'] > 0:
|
||||||
|
existing_lead.google_rating = item['google_rating']
|
||||||
|
if item.get('total_avaliacoes'):
|
||||||
|
existing_lead.total_avaliacoes = item['total_avaliacoes']
|
||||||
|
if item.get('google_maps_url'):
|
||||||
|
existing_lead.google_maps_url = item['google_maps_url']
|
||||||
|
|
||||||
|
existing_lead.atualizado_em = db.func.now()
|
||||||
|
|
||||||
|
interacao = LeadInteracao(
|
||||||
|
lead_id=existing_lead.id,
|
||||||
|
usuario_id=usuario_id,
|
||||||
|
tipo='lead_atualizado',
|
||||||
|
descricao='Lead re-encontrado em nova busca e atualizado.',
|
||||||
|
metadados={'ramo_busca': ramo, 'cep_busca': cep}
|
||||||
|
)
|
||||||
|
db.session.add(interacao)
|
||||||
|
updated_count += 1
|
||||||
|
persisted_leads.append(existing_lead)
|
||||||
|
else:
|
||||||
|
# Novo Lead
|
||||||
|
new_lead = Lead(
|
||||||
|
nome_empresa=nome_empresa,
|
||||||
|
ramo_atividade=ramo,
|
||||||
|
cep_busca=location.get('formatted_cep', cep),
|
||||||
|
logradouro=location.get('logradouro') or item.get('endereco', ''),
|
||||||
|
bairro=location.get('bairro', ''),
|
||||||
|
cidade=cidade,
|
||||||
|
uf=location.get('uf', ''),
|
||||||
|
telefone=tel_raw,
|
||||||
|
telefone_sanitizado=tel_sanitizado,
|
||||||
|
whatsapp_valido=True if tel_sanitizado else False,
|
||||||
|
website=item.get('website', ''),
|
||||||
|
google_rating=item.get('google_rating', 0.0),
|
||||||
|
total_avaliacoes=item.get('total_avaliacoes', 0),
|
||||||
|
google_maps_url=item.get('google_maps_url', ''),
|
||||||
|
status_funil='novo',
|
||||||
|
tags=[ramo],
|
||||||
|
notas=f"Capturado via Radar de Busca em {cep}.",
|
||||||
|
usuario_responsavel_id=usuario_id
|
||||||
|
)
|
||||||
|
db.session.add(new_lead)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
interacao = LeadInteracao(
|
||||||
|
lead_id=new_lead.id,
|
||||||
|
usuario_id=usuario_id,
|
||||||
|
tipo='lead_criado',
|
||||||
|
descricao=f'Lead capturado no Maps para ramo "{ramo}" e CEP {cep}.',
|
||||||
|
metadados={'query_cep': cep, 'ramo': ramo}
|
||||||
|
)
|
||||||
|
db.session.add(interacao)
|
||||||
|
created_count += 1
|
||||||
|
persisted_leads.append(new_lead)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'location': location,
|
||||||
|
'created_count': created_count,
|
||||||
|
'updated_count': updated_count,
|
||||||
|
'total_processed': len(scraped_items),
|
||||||
|
'leads': [l.to_dict() for l in persisted_leads]
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from typing import Dict, Any, Tuple
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.lead import Lead
|
||||||
|
from app.models.lead_interacao import LeadInteracao
|
||||||
|
from app.utils.sanitizers import sanitize_phone
|
||||||
|
|
||||||
|
class WebhookService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_n8n_event(payload: Dict[str, Any]) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Processa eventos recebidos do n8n para atualizar leads e gerar logs de auditoria.
|
||||||
|
"""
|
||||||
|
if not payload:
|
||||||
|
return False, "Payload vazio.", {}
|
||||||
|
|
||||||
|
lead_id = payload.get('lead_id')
|
||||||
|
telefone = payload.get('telefone')
|
||||||
|
event_type = payload.get('event_type', 'webhook_n8n')
|
||||||
|
new_status = payload.get('new_status')
|
||||||
|
mensagem = payload.get('mensagem') or payload.get('descricao', 'Evento recebido via Webhook n8n')
|
||||||
|
|
||||||
|
lead = None
|
||||||
|
if lead_id:
|
||||||
|
lead = db.session.get(Lead, lead_id)
|
||||||
|
|
||||||
|
if not lead and telefone:
|
||||||
|
sanitized = sanitize_phone(telefone)
|
||||||
|
if sanitized:
|
||||||
|
lead = Lead.query.filter_by(telefone_sanitizado=sanitized).first()
|
||||||
|
|
||||||
|
if not lead:
|
||||||
|
return False, "Lead correspondente não foi localizado no banco.", {}
|
||||||
|
|
||||||
|
# Se houver atualização de status
|
||||||
|
if new_status and new_status in ['novo', 'contatado', 'respondeu', 'negociacao', 'ganho', 'perdido', 'opt_out']:
|
||||||
|
old_status = lead.status_funil
|
||||||
|
lead.status_funil = new_status
|
||||||
|
lead.atualizado_em = db.func.now()
|
||||||
|
|
||||||
|
interacao_status = LeadInteracao(
|
||||||
|
lead_id=lead.id,
|
||||||
|
tipo='status_change',
|
||||||
|
descricao=f'Status alterado via webhook n8n de "{old_status}" para "{new_status}".',
|
||||||
|
metadados={'origem': 'n8n', 'old_status': old_status, 'new_status': new_status}
|
||||||
|
)
|
||||||
|
db.session.add(interacao_status)
|
||||||
|
|
||||||
|
# Registra a interação principal do webhook
|
||||||
|
interacao_webhook = LeadInteracao(
|
||||||
|
lead_id=lead.id,
|
||||||
|
tipo='webhook_n8n',
|
||||||
|
descricao=f"Webhook [{event_type}]: {mensagem}",
|
||||||
|
metadados=payload
|
||||||
|
)
|
||||||
|
db.session.add(interacao_webhook)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return True, "Webhook processado com sucesso.", {
|
||||||
|
'lead_id': lead.id,
|
||||||
|
'lead_nome': lead.nome_empresa,
|
||||||
|
'status_atual': lead.status_funil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import jsonify
|
||||||
|
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
def get_current_user():
|
||||||
|
"""
|
||||||
|
Retorna o objeto User autenticado no JWT atual.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_jwt_in_request()
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
return db.session.get(User, user_id)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def admin_required():
|
||||||
|
"""
|
||||||
|
Decorator que exige que o usuário autenticado tenha role == 'admin'.
|
||||||
|
Caso contrário, bloqueia com 403 Forbidden.
|
||||||
|
"""
|
||||||
|
def decorator(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
verify_jwt_in_request()
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user or not user.ativo:
|
||||||
|
return jsonify({'error': 'Usuário inválido ou inativo.'}), 401
|
||||||
|
if user.role != 'admin':
|
||||||
|
return jsonify({'error': 'Acesso negado. Requer perfil de administrador.'}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
def sanitize_phone(phone_str: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitiza um número de telefone para o formato E.164 brasileiro (ex: 5511999999999).
|
||||||
|
Remove caracteres não numéricos e adiciona DDI 55 se ausente.
|
||||||
|
"""
|
||||||
|
if not phone_str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Manter apenas digitos
|
||||||
|
digits = re.sub(r'\D', '', str(phone_str))
|
||||||
|
|
||||||
|
if not digits:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Se já tiver o DDI 55 e tiver 12 ou 13 dígitos
|
||||||
|
if digits.startswith('55') and len(digits) in (12, 13):
|
||||||
|
return digits
|
||||||
|
|
||||||
|
# Se tiver 10 (fixo com DDD) ou 11 (celular com DDD) dígitos
|
||||||
|
if len(digits) in (10, 11):
|
||||||
|
return f"55{digits}"
|
||||||
|
|
||||||
|
return digits
|
||||||
|
|
||||||
|
def sanitize_cep(cep_str: str) -> str:
|
||||||
|
"""
|
||||||
|
Limpa o CEP retornando apenas 8 dígitos numéricos.
|
||||||
|
"""
|
||||||
|
if not cep_str:
|
||||||
|
return ""
|
||||||
|
digits = re.sub(r'\D', '', str(cep_str))
|
||||||
|
if len(digits) == 8:
|
||||||
|
return digits
|
||||||
|
return digits
|
||||||
|
|
||||||
|
def validate_email(email_str: str) -> bool:
|
||||||
|
"""
|
||||||
|
Valida formato basico de email.
|
||||||
|
"""
|
||||||
|
if not email_str:
|
||||||
|
return False
|
||||||
|
regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||||
|
return bool(re.match(regex, email_str.strip()))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Flask>=3.0.0
|
||||||
|
Flask-SQLAlchemy>=3.1.0
|
||||||
|
Flask-Bcrypt>=1.0.1
|
||||||
|
Flask-JWT-Extended>=4.6.0
|
||||||
|
Flask-CORS>=4.0.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
playwright>=1.40.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-flask>=1.3.0
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import pytest
|
||||||
|
from app import create_app
|
||||||
|
from app.config import TestConfig
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.user import User
|
||||||
|
from flask_jwt_extended import create_access_token
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
app = create_app(TestConfig)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield app
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user(app):
|
||||||
|
user = User(
|
||||||
|
nome="Admin Tester",
|
||||||
|
email="admin@test.com",
|
||||||
|
role="admin",
|
||||||
|
ativo=True
|
||||||
|
)
|
||||||
|
user.set_password("AdminPass123!")
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def regular_user(app):
|
||||||
|
user = User(
|
||||||
|
nome="User Tester",
|
||||||
|
email="user@test.com",
|
||||||
|
role="user",
|
||||||
|
ativo=True
|
||||||
|
)
|
||||||
|
user.set_password("UserPass123!")
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_headers(app, admin_user):
|
||||||
|
token = create_access_token(identity=admin_user.id, additional_claims={'role': 'admin'})
|
||||||
|
return {'Authorization': f'Bearer {token}'}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_headers(app, regular_user):
|
||||||
|
token = create_access_token(identity=regular_user.id, additional_claims={'role': 'user'})
|
||||||
|
return {'Authorization': f'Bearer {token}'}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
def test_login_success(client, regular_user):
|
||||||
|
response = client.post('/api/v1/auth/login', json={
|
||||||
|
'email': 'user@test.com',
|
||||||
|
'password': 'UserPass123!'
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'access_token' in data
|
||||||
|
assert data['user']['email'] == 'user@test.com'
|
||||||
|
assert data['user']['role'] == 'user'
|
||||||
|
|
||||||
|
def test_login_invalid_password(client, regular_user):
|
||||||
|
response = client.post('/api/v1/auth/login', json={
|
||||||
|
'email': 'user@test.com',
|
||||||
|
'password': 'WrongPassword'
|
||||||
|
})
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'error' in data
|
||||||
|
|
||||||
|
def test_get_me_profile(client, user_headers):
|
||||||
|
response = client.get('/api/v1/auth/me', headers=user_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['email'] == 'user@test.com'
|
||||||
|
|
||||||
|
def test_change_password_success(client, user_headers):
|
||||||
|
response = client.post('/api/v1/auth/change-password', headers=user_headers, json={
|
||||||
|
'old_password': 'UserPass123!',
|
||||||
|
'new_password': 'NewSuperPass123!'
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'message' in response.get_json()
|
||||||
|
|
||||||
|
# Tentar login com a nova senha
|
||||||
|
login_res = client.post('/api/v1/auth/login', json={
|
||||||
|
'email': 'user@test.com',
|
||||||
|
'password': 'NewSuperPass123!'
|
||||||
|
})
|
||||||
|
assert login_res.status_code == 200
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
def test_search_maps_endpoint(client, user_headers):
|
||||||
|
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||||
|
mock_geo.return_value = {
|
||||||
|
'cep': '01310100',
|
||||||
|
'logradouro': 'Avenida Paulista',
|
||||||
|
'bairro': 'Bela Vista',
|
||||||
|
'cidade': 'São Paulo',
|
||||||
|
'uf': 'SP',
|
||||||
|
'formatted_cep': '01310-100'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/api/v1/leads/search-maps', headers=user_headers, json={
|
||||||
|
'cep': '01310-100',
|
||||||
|
'ramo': 'Padaria',
|
||||||
|
'max_results': 5
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'summary' in data
|
||||||
|
assert data['summary']['created_count'] >= 1
|
||||||
|
|
||||||
|
def test_update_lead_status_and_interaction(client, user_headers):
|
||||||
|
# Primeiro dispara busca para criar lead
|
||||||
|
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||||
|
mock_geo.return_value = {
|
||||||
|
'cep': '01310100', 'logradouro': 'Avenida Paulista', 'bairro': 'Bela Vista',
|
||||||
|
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||||
|
}
|
||||||
|
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Padaria'})
|
||||||
|
|
||||||
|
# Listar leads
|
||||||
|
list_res = client.get('/api/v1/leads', headers=user_headers)
|
||||||
|
assert list_res.status_code == 200
|
||||||
|
leads = list_res.get_json()['leads']
|
||||||
|
assert len(leads) > 0
|
||||||
|
lead_id = leads[0]['id']
|
||||||
|
|
||||||
|
# Atualizar status para 'contatado'
|
||||||
|
update_res = client.put(f'/api/v1/leads/{lead_id}', headers=user_headers, json={
|
||||||
|
'status_funil': 'contatado',
|
||||||
|
'notas': 'Primeiro contato realizado via WhatsApp.'
|
||||||
|
})
|
||||||
|
assert update_res.status_code == 200
|
||||||
|
assert update_res.get_json()['status_funil'] == 'contatado'
|
||||||
|
|
||||||
|
# Verificar historico de interacoes
|
||||||
|
detail_res = client.get(f'/api/v1/leads/{lead_id}', headers=user_headers)
|
||||||
|
assert detail_res.status_code == 200
|
||||||
|
detail = detail_res.get_json()
|
||||||
|
assert len(detail['interacoes']) >= 2
|
||||||
|
types = [i['tipo'] for i in detail['interacoes']]
|
||||||
|
assert 'status_change' in types
|
||||||
|
|
||||||
|
def test_opt_out_lgpd(client, user_headers):
|
||||||
|
# Criar lead
|
||||||
|
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||||
|
mock_geo.return_value = {
|
||||||
|
'cep': '01310100', 'logradouro': 'Rua Teste', 'bairro': 'Centro',
|
||||||
|
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||||
|
}
|
||||||
|
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Farmácia'})
|
||||||
|
|
||||||
|
list_res = client.get('/api/v1/leads', headers=user_headers)
|
||||||
|
lead_id = list_res.get_json()['leads'][0]['id']
|
||||||
|
|
||||||
|
# Opt-out
|
||||||
|
opt_res = client.post(f'/api/v1/leads/{lead_id}/opt-out', headers=user_headers)
|
||||||
|
assert opt_res.status_code == 200
|
||||||
|
assert opt_res.get_json()['lead']['status_funil'] == 'opt_out'
|
||||||
|
|
||||||
|
def test_export_leads(client, user_headers):
|
||||||
|
# Exportar CSV
|
||||||
|
csv_res = client.get('/api/v1/leads/export?format=csv', headers=user_headers)
|
||||||
|
assert csv_res.status_code == 200
|
||||||
|
assert 'text/csv' in csv_res.content_type
|
||||||
|
|
||||||
|
# Exportar JSON
|
||||||
|
json_res = client.get('/api/v1/leads/export?format=json', headers=user_headers)
|
||||||
|
assert json_res.status_code == 200
|
||||||
|
assert isinstance(json_res.get_json(), list)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
def test_admin_can_list_users(client, admin_headers):
|
||||||
|
response = client.get('/api/v1/users', headers=admin_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
def test_user_cannot_list_users(client, user_headers):
|
||||||
|
response = client.get('/api/v1/users', headers=user_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'Acesso negado' in data['error']
|
||||||
|
|
||||||
|
def test_user_cannot_create_user(client, user_headers):
|
||||||
|
response = client.post('/api/v1/users', headers=user_headers, json={
|
||||||
|
'nome': 'Hacker User',
|
||||||
|
'email': 'hacker@test.com',
|
||||||
|
'password': 'Password123!',
|
||||||
|
'role': 'user'
|
||||||
|
})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_user_cannot_delete_user(client, user_headers, admin_user):
|
||||||
|
response = client.delete(f'/api/v1/users/{admin_user.id}', headers=user_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_user_cannot_expunge_database(client, user_headers):
|
||||||
|
response = client.delete('/api/v1/leads/bulk', headers=user_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_admin_can_create_and_delete_user(client, admin_headers):
|
||||||
|
# Criar usuario
|
||||||
|
create_res = client.post('/api/v1/users', headers=admin_headers, json={
|
||||||
|
'nome': 'Novo Operador',
|
||||||
|
'email': 'operador@test.com',
|
||||||
|
'password': 'OperadorPass123!',
|
||||||
|
'role': 'user'
|
||||||
|
})
|
||||||
|
assert create_res.status_code == 201
|
||||||
|
new_user_id = create_res.get_json()['id']
|
||||||
|
|
||||||
|
# Deletar usuario
|
||||||
|
delete_res = client.delete(f'/api/v1/users/{new_user_id}', headers=admin_headers)
|
||||||
|
assert delete_res.status_code == 200
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from app.utils.sanitizers import sanitize_phone, sanitize_cep, validate_email
|
||||||
|
from app.services.geocoding_service import GeocodingService
|
||||||
|
|
||||||
|
def test_sanitize_phone():
|
||||||
|
assert sanitize_phone('(11) 99999-8888') == '5511999998888'
|
||||||
|
assert sanitize_phone('1133334444') == '551133334444'
|
||||||
|
assert sanitize_phone('5511988887777') == '5511988887777'
|
||||||
|
assert sanitize_phone('') == ''
|
||||||
|
|
||||||
|
def test_sanitize_cep():
|
||||||
|
assert sanitize_cep('01310-100') == '01310100'
|
||||||
|
assert sanitize_cep('01310100') == '01310100'
|
||||||
|
assert sanitize_cep('invalid') == ''
|
||||||
|
|
||||||
|
def test_validate_email():
|
||||||
|
assert validate_email('admin@leadradar.com') is True
|
||||||
|
assert validate_email('invalid-email') is False
|
||||||
|
|
||||||
|
def test_build_search_query():
|
||||||
|
location = {'bairro': 'Bela Vista', 'cidade': 'São Paulo', 'uf': 'SP'}
|
||||||
|
query = GeocodingService.build_search_query('Restaurante', location)
|
||||||
|
assert query == 'Restaurante em Bela Vista, São Paulo - SP'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
def test_n8n_webhook_flow(client, user_headers):
|
||||||
|
# Criar lead
|
||||||
|
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||||
|
mock_geo.return_value = {
|
||||||
|
'cep': '01310100', 'logradouro': 'Rua Teste', 'bairro': 'Centro',
|
||||||
|
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||||
|
}
|
||||||
|
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Supermercado'})
|
||||||
|
|
||||||
|
leads = client.get('/api/v1/leads', headers=user_headers).get_json()['leads']
|
||||||
|
lead_id = leads[0]['id']
|
||||||
|
|
||||||
|
# Disparar webhook n8n
|
||||||
|
webhook_res = client.post('/api/v1/webhooks/n8n', json={
|
||||||
|
'lead_id': lead_id,
|
||||||
|
'event_type': 'mensagem_enviada',
|
||||||
|
'new_status': 'respondeu',
|
||||||
|
'mensagem': 'Cliente respondeu via WhatsApp demonstrando interesse.'
|
||||||
|
})
|
||||||
|
assert webhook_res.status_code == 200
|
||||||
|
assert webhook_res.get_json()['data']['status_atual'] == 'respondeu'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: leadradar-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: leadradar_db
|
||||||
|
POSTGRES_USER: leadradar_user
|
||||||
|
POSTGRES_PASSWORD: leadradar_pass_2026
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U leadradar_user -d leadradar_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: leadradar-api
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
- SECRET_KEY=leadradar-production-secret-key-32-bytes-long
|
||||||
|
- JWT_SECRET_KEY=leadradar-jwt-production-secret-key-32-bytes
|
||||||
|
- DATABASE_URL=postgresql://leadradar_user:leadradar_pass_2026@db:5432/leadradar_db
|
||||||
|
- PLAYWRIGHT_HEADLESS=true
|
||||||
|
- N8N_WEBHOOK_SECRET=n8n-leadradar-secret-token
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: leadradar-ui
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8501:8501"
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Dockerfile for LeadRadar Streamlit Frontend
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://127.0.0.1:5000')
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = BACKEND_URL.rstrip('/')
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict:
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
token = st.session_state.get('access_token')
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def login(self, email: str, password: str) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/auth/login"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.post(url, json={'email': email, 'password': password})
|
||||||
|
data = res.json()
|
||||||
|
if res.status_code == 200:
|
||||||
|
st.session_state['access_token'] = data.get('access_token')
|
||||||
|
st.session_state['refresh_token'] = data.get('refresh_token')
|
||||||
|
st.session_state['user'] = data.get('user')
|
||||||
|
return True, "Login realizado com sucesso!"
|
||||||
|
return False, data.get('error', 'Falha ao autenticar.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro de conexão com o servidor API: {str(e)}"
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
st.session_state.pop('access_token', None)
|
||||||
|
st.session_state.pop('refresh_token', None)
|
||||||
|
st.session_state.pop('user', None)
|
||||||
|
|
||||||
|
def change_password(self, old_password: str, new_password: str) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/auth/change-password"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.post(url, headers=self._get_headers(), json={
|
||||||
|
'old_password': old_password,
|
||||||
|
'new_password': new_password
|
||||||
|
})
|
||||||
|
data = res.json()
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, data.get('message', 'Senha alterada com sucesso.')
|
||||||
|
return False, data.get('error', 'Falha ao alterar senha.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro ao conectar com API: {str(e)}"
|
||||||
|
|
||||||
|
def search_maps(self, cep: str, ramo: str, max_results: int = 15) -> tuple[bool, dict]:
|
||||||
|
url = f"{self.base_url}/api/v1/leads/search-maps"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
res = client.post(url, headers=self._get_headers(), json={
|
||||||
|
'cep': cep,
|
||||||
|
'ramo': ramo,
|
||||||
|
'max_results': max_results
|
||||||
|
})
|
||||||
|
data = res.json()
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, data
|
||||||
|
return False, {'error': data.get('error', 'Erro durante varredura no Google Maps.')}
|
||||||
|
except Exception as e:
|
||||||
|
return False, {'error': f"Erro ao processar busca: {str(e)}"}
|
||||||
|
|
||||||
|
def get_leads(self, status_funil: str = None, cidade: str = None, ramo: str = None, search: str = None) -> list:
|
||||||
|
url = f"{self.base_url}/api/v1/leads"
|
||||||
|
params = {}
|
||||||
|
if status_funil: params['status_funil'] = status_funil
|
||||||
|
if cidade: params['cidade'] = cidade
|
||||||
|
if ramo: params['ramo'] = ramo
|
||||||
|
if search: params['search'] = search
|
||||||
|
params['per_page'] = 200
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
res = client.get(url, headers=self._get_headers(), params=params)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return res.json().get('leads', [])
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_lead_details(self, lead_id: str) -> dict:
|
||||||
|
url = f"{self.base_url}/api/v1/leads/{lead_id}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.get(url, headers=self._get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return res.json()
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update_lead(self, lead_id: str, payload: dict) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/leads/{lead_id}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.put(url, headers=self._get_headers(), json=payload)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, "Lead atualizado."
|
||||||
|
return False, res.json().get('error', 'Falha ao atualizar lead.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro ao comunicar com servidor: {str(e)}"
|
||||||
|
|
||||||
|
def opt_out_lead(self, lead_id: str) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/leads/{lead_id}/opt-out"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.post(url, headers=self._get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, res.json().get('message', 'Opt-out registrado com sucesso.')
|
||||||
|
return False, res.json().get('error', 'Erro ao processar opt-out.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro: {str(e)}"
|
||||||
|
|
||||||
|
def get_users(self) -> list:
|
||||||
|
url = f"{self.base_url}/api/v1/users"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.get(url, headers=self._get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return res.json()
|
||||||
|
return []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_user(self, nome: str, email: str, password: str, role: str) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/users"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.post(url, headers=self._get_headers(), json={
|
||||||
|
'nome': nome,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'role': role
|
||||||
|
})
|
||||||
|
data = res.json()
|
||||||
|
if res.status_code == 201:
|
||||||
|
return True, f"Usuário {email} criado com sucesso!"
|
||||||
|
return False, data.get('error', 'Erro ao criar usuário.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro ao conectar com API: {str(e)}"
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str) -> tuple[bool, str]:
|
||||||
|
url = f"{self.base_url}/api/v1/users/{user_id}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.delete(url, headers=self._get_headers())
|
||||||
|
data = res.json()
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, data.get('message', 'Usuário excluído.')
|
||||||
|
return False, data.get('error', 'Erro ao excluir usuário.')
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Erro ao conectar com API: {str(e)}"
|
||||||
|
|
||||||
|
def export_leads_data(self, format_type: str = 'csv') -> bytes:
|
||||||
|
url = f"{self.base_url}/api/v1/leads/export?format={format_type}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
res = client.get(url, headers=self._get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return res.content
|
||||||
|
return b""
|
||||||
|
except Exception:
|
||||||
|
return b""
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="LeadRadar - Prospecção Ativa & Funil CRM",
|
||||||
|
page_icon="📡",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Carregar CSS customizado Dark Tech Slate
|
||||||
|
css_path = os.path.join(os.path.dirname(__file__), 'styles', 'style.css')
|
||||||
|
if os.path.exists(css_path):
|
||||||
|
with open(css_path, 'r', encoding='utf-8') as f:
|
||||||
|
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
from api_client import APIClient
|
||||||
|
from views.login_view import render_login_view
|
||||||
|
from views.radar_busca import render_radar_busca_view
|
||||||
|
from views.crm_kanban import render_crm_kanban_view
|
||||||
|
from views.analytical_table import render_analytical_table_view
|
||||||
|
from views.admin_users import render_admin_users_view
|
||||||
|
from views.profile import render_profile_view
|
||||||
|
|
||||||
|
def main():
|
||||||
|
api_client = APIClient()
|
||||||
|
|
||||||
|
# Verificar se o usuário está autenticado
|
||||||
|
if not st.session_state.get('access_token'):
|
||||||
|
render_login_view(api_client)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_info = st.session_state.get('user', {})
|
||||||
|
is_admin = user_info.get('role') == 'admin'
|
||||||
|
|
||||||
|
# Barra Lateral (Sidebar Navigation)
|
||||||
|
with st.sidebar:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<h2 style="color: #58A6FF; margin-bottom: 0;">📡 LeadRadar</h2>
|
||||||
|
<small style="color: #8B949E;">Prospecção & Mini-CRM</small>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div style="background-color: #161B22; border: 1px solid #30363D; border-radius: 8px; padding: 10px; margin-bottom: 20px;">
|
||||||
|
<div style="font-weight: 600; color: #E6EDF3;">{user_info.get('nome', 'Usuário')}</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #8B949E;">{user_info.get('email', '')}</div>
|
||||||
|
<span class="lead-badge" style="margin-top: 6px;">{user_info.get('role', 'user').upper()}</span>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
nav_options = [
|
||||||
|
"📡 Radar de Busca",
|
||||||
|
"📌 Funil CRM (Kanban)",
|
||||||
|
"📊 Tabela Analítica"
|
||||||
|
]
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
nav_options.append("🛡️ Usuários (Admin)")
|
||||||
|
|
||||||
|
nav_options.append("👤 Meu Perfil")
|
||||||
|
|
||||||
|
selected_page = st.radio("Navegação Principal", nav_options, index=0)
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
if st.button("🚪 Sair (Logout)", use_container_width=True):
|
||||||
|
api_client.logout()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Renderização da Página Selecionada
|
||||||
|
if selected_page == "📡 Radar de Busca":
|
||||||
|
render_radar_busca_view(api_client)
|
||||||
|
elif selected_page == "📌 Funil CRM (Kanban)":
|
||||||
|
render_crm_kanban_view(api_client)
|
||||||
|
elif selected_page == "📊 Tabela Analítica":
|
||||||
|
render_analytical_table_view(api_client)
|
||||||
|
elif selected_page == "🛡️ Usuários (Admin)":
|
||||||
|
render_admin_users_view(api_client)
|
||||||
|
elif selected_page == "👤 Meu Perfil":
|
||||||
|
render_profile_view(api_client)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
STATUS_CONFIG = [
|
||||||
|
('novo', 'Novo', '#58A6FF'),
|
||||||
|
('contatado', 'Contatado', '#D29922'),
|
||||||
|
('respondeu', 'Respondeu', '#A371F7'),
|
||||||
|
('negociacao', 'Em Negociação', '#F0883E'),
|
||||||
|
('ganho', 'Ganho', '#3FB950'),
|
||||||
|
('perdido', 'Perdido', '#F85149')
|
||||||
|
]
|
||||||
|
|
||||||
|
def render_kanban_board(leads: list, api_client: APIClient):
|
||||||
|
cols = st.columns(len(STATUS_CONFIG))
|
||||||
|
|
||||||
|
# Agrupar leads por status
|
||||||
|
leads_by_status = {key: [] for key, _, _ in STATUS_CONFIG}
|
||||||
|
for l in leads:
|
||||||
|
st_key = l.get('status_funil', 'novo')
|
||||||
|
if st_key in leads_by_status:
|
||||||
|
leads_by_status[st_key].append(l)
|
||||||
|
|
||||||
|
for idx, (status_key, status_label, color) in enumerate(STATUS_CONFIG):
|
||||||
|
with cols[idx]:
|
||||||
|
col_leads = leads_by_status[status_key]
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="kanban-col-header" style="border-bottom-color: {color};">
|
||||||
|
<span>{status_label}</span>
|
||||||
|
<span class="lead-badge" style="background-color: rgba(255,255,255,0.1); color: var(--text-primary);">
|
||||||
|
{len(col_leads)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for lead in col_leads:
|
||||||
|
lead_id = lead['id']
|
||||||
|
nome = lead.get('nome_empresa', 'Empresa')
|
||||||
|
ramo = lead.get('ramo_atividade', '')
|
||||||
|
tel = lead.get('telefone', '')
|
||||||
|
tel_san = lead.get('telefone_sanitizado', '')
|
||||||
|
rating = lead.get('google_rating', 0.0)
|
||||||
|
reviews = lead.get('total_avaliacoes', 0)
|
||||||
|
maps_url = lead.get('google_maps_url', '')
|
||||||
|
|
||||||
|
rating_str = f"⭐ {rating:.1f} ({reviews})" if rating > 0 else "Sem avaliação"
|
||||||
|
|
||||||
|
# Container visual do Card
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown(f"**{nome}**")
|
||||||
|
st.caption(f"🏷️ {ramo} | 📍 {lead.get('bairro', '') or lead.get('cidade', '')}")
|
||||||
|
st.caption(f"📊 {rating_str}")
|
||||||
|
|
||||||
|
if tel_san:
|
||||||
|
st.markdown(
|
||||||
|
f'<a href="https://wa.me/{tel_san}" target="_blank" class="whatsapp-link">💬 WhatsApp ({tel})</a>',
|
||||||
|
unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
elif tel:
|
||||||
|
st.caption(f"📞 {tel}")
|
||||||
|
|
||||||
|
if maps_url:
|
||||||
|
st.markdown(f"[📍 Abrir no Google Maps]({maps_url})")
|
||||||
|
|
||||||
|
c1, c2 = st.columns([2, 1])
|
||||||
|
with c1:
|
||||||
|
opts = [s[1] for s in STATUS_CONFIG]
|
||||||
|
cur_idx = [s[0] for s in STATUS_CONFIG].index(status_key)
|
||||||
|
new_label = st.selectbox(
|
||||||
|
"Mover para:",
|
||||||
|
opts,
|
||||||
|
index=cur_idx,
|
||||||
|
key=f"st_sel_{lead_id}",
|
||||||
|
label_visibility="collapsed"
|
||||||
|
)
|
||||||
|
new_st_key = [s[0] for s in STATUS_CONFIG][opts.index(new_label)]
|
||||||
|
if new_st_key != status_key:
|
||||||
|
api_client.update_lead(lead_id, {'status_funil': new_st_key})
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
if st.button("📝", key=f"btn_notes_{lead_id}", help="Editar notas e ver histórico"):
|
||||||
|
st.session_state['selected_lead_id'] = lead_id
|
||||||
|
st.rerun()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
def render_metrics_summary(leads: list):
|
||||||
|
total = len(leads)
|
||||||
|
novos = sum(1 for l in leads if l.get('status_funil') == 'novo')
|
||||||
|
contatados = sum(1 for l in leads if l.get('status_funil') == 'contatado')
|
||||||
|
negociacao = sum(1 for l in leads if l.get('status_funil') == 'negociacao')
|
||||||
|
ganhos = sum(1 for l in leads if l.get('status_funil') == 'ganho')
|
||||||
|
taxa_conversao = (ganhos / total * 100) if total > 0 else 0.0
|
||||||
|
|
||||||
|
c1, c2, c3, c4, c5 = st.columns(5)
|
||||||
|
|
||||||
|
with c1:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">Total de Leads</div>
|
||||||
|
<div class="metric-value">{total}</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">Leads Novos</div>
|
||||||
|
<div class="metric-value" style="color: #58A6FF;">{novos}</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with c3:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">Em Negociação</div>
|
||||||
|
<div class="metric-value" style="color: #F0883E;">{negociacao}</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with c4:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">Ganhos</div>
|
||||||
|
<div class="metric-value" style="color: #3FB950;">{ganhos}</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with c5:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-title">Taxa Conversão</div>
|
||||||
|
<div class="metric-value" style="color: #A371F7;">{taxa_conversao:.1f}%</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
streamlit>=1.35.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
pandas>=2.2.0
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* LeadRadar Dark Tech Slate Theme */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0D1117;
|
||||||
|
--bg-secondary: #161B22;
|
||||||
|
--bg-tertiary: #21262D;
|
||||||
|
--border-color: #30363D;
|
||||||
|
--text-primary: #E6EDF3;
|
||||||
|
--text-secondary: #8B949E;
|
||||||
|
--accent-blue: #58A6FF;
|
||||||
|
--accent-blue-hover: #1F6FEB;
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--status-novo: #58A6FF;
|
||||||
|
--status-contatado: #D29922;
|
||||||
|
--status-respondeu: #A371F7;
|
||||||
|
--status-negociacao: #F0883E;
|
||||||
|
--status-ganho: #3FB950;
|
||||||
|
--status-perdido: #F85149;
|
||||||
|
--status-optout: #8B949E;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Metric Card */
|
||||||
|
.metric-card {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-title {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kanban Board Styling */
|
||||||
|
.kanban-col {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col-header {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-subtitle {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: rgba(88, 166, 255, 0.15);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
border: 1px solid rgba(88, 166, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whatsapp-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #25D366;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: rgba(37, 211, 102, 0.1);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(37, 211, 102, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.whatsapp-link:hover {
|
||||||
|
background-color: rgba(37, 211, 102, 0.2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
def render_admin_users_view(api_client: APIClient):
|
||||||
|
st.title("🛡️ Gestão de Usuários & Segurança (Admin)")
|
||||||
|
|
||||||
|
user_info = st.session_state.get('user', {})
|
||||||
|
if user_info.get('role') != 'admin':
|
||||||
|
st.error("🚫 Acesso restrito! Esta página é exclusiva para administradores.")
|
||||||
|
return
|
||||||
|
|
||||||
|
c1, c2 = st.columns([2, 1])
|
||||||
|
|
||||||
|
with c1:
|
||||||
|
st.subheader("👥 Operadores e Administradores")
|
||||||
|
users = api_client.get_users()
|
||||||
|
|
||||||
|
if users:
|
||||||
|
df_users = pd.DataFrame([
|
||||||
|
{
|
||||||
|
'ID': u['id'],
|
||||||
|
'Nome': u['nome'],
|
||||||
|
'E-mail': u['email'],
|
||||||
|
'Perfil': u['role'].upper(),
|
||||||
|
'Ativo': 'Sim' if u['ativo'] else 'Não',
|
||||||
|
'Criado em': u['criado_em'][:10] if u.get('criado_em') else ''
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
])
|
||||||
|
st.dataframe(df_users, use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
st.markdown("### ❌ Excluir Operador")
|
||||||
|
user_to_delete = st.selectbox(
|
||||||
|
"Selecione um usuário para remover:",
|
||||||
|
options=[u for u in users if u['id'] != user_info.get('id')],
|
||||||
|
format_func=lambda u: f"{u['nome']} ({u['email']}) - {u['role'].upper()}"
|
||||||
|
)
|
||||||
|
if user_to_delete:
|
||||||
|
if st.button(f"🗑️ Confirmar Exclusão de {user_to_delete['nome']}", type="secondary"):
|
||||||
|
success, msg = api_client.delete_user(user_to_delete['id'])
|
||||||
|
if success:
|
||||||
|
st.success(msg)
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.error(msg)
|
||||||
|
else:
|
||||||
|
st.warning("Não foi possível carregar a lista de usuários.")
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("➕ Novo Usuário")
|
||||||
|
nome = st.text_input("Nome Completo", key="add_nome")
|
||||||
|
email = st.text_input("E-mail", key="add_email")
|
||||||
|
password = st.text_input("Senha", type="password", key="add_pass")
|
||||||
|
role = st.selectbox("Perfil de Acesso", ["user", "admin"], format_func=lambda r: "Operador (User)" if r == "user" else "Administrador (Admin)")
|
||||||
|
|
||||||
|
if st.button("✨ Criar Usuário", type="primary", use_container_width=True):
|
||||||
|
if not nome or not email or not password:
|
||||||
|
st.error("Preencha todos os campos obrigatórios.")
|
||||||
|
else:
|
||||||
|
success, msg = api_client.create_user(nome, email, password, role)
|
||||||
|
if success:
|
||||||
|
st.success(msg)
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.error(msg)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
def render_analytical_table_view(api_client: APIClient):
|
||||||
|
st.title("📊 Tabela Analítica & Exportação de Leads")
|
||||||
|
st.caption("Filtre, analise e exporte seus leads nos formatos CSV e JSON.")
|
||||||
|
|
||||||
|
leads = api_client.get_leads()
|
||||||
|
|
||||||
|
# Controles de Exportação
|
||||||
|
exp_col1, exp_col2, _ = st.columns([1, 1, 2])
|
||||||
|
with exp_col1:
|
||||||
|
csv_bytes = api_client.export_leads_data('csv')
|
||||||
|
st.download_button(
|
||||||
|
label="📥 Exportar em CSV",
|
||||||
|
data=csv_bytes,
|
||||||
|
file_name="leads_leadradar.csv",
|
||||||
|
mime="text/csv",
|
||||||
|
use_container_width=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with exp_col2:
|
||||||
|
json_bytes = api_client.export_leads_data('json')
|
||||||
|
st.download_button(
|
||||||
|
label="📥 Exportar em JSON",
|
||||||
|
data=json_bytes,
|
||||||
|
file_name="leads_leadradar.json",
|
||||||
|
mime="application/json",
|
||||||
|
use_container_width=True
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Filtros Avançados
|
||||||
|
f1, f2, f3, f4 = st.columns(4)
|
||||||
|
with f1:
|
||||||
|
search = st.text_input("🔍 Busca Geral:", value="", key="tbl_search")
|
||||||
|
with f2:
|
||||||
|
statuses = ["Todos", "novo", "contatado", "respondeu", "negociacao", "ganho", "perdido", "opt_out"]
|
||||||
|
status_sel = st.selectbox("Status Funil:", statuses, key="tbl_status")
|
||||||
|
with f3:
|
||||||
|
cidades = ["Todas"] + sorted(list(set(l.get('cidade', '') for l in leads if l.get('cidade'))))
|
||||||
|
cidade_sel = st.selectbox("Cidade:", cidades, key="tbl_cidade")
|
||||||
|
with f4:
|
||||||
|
ramos = ["Todos"] + sorted(list(set(l.get('ramo_atividade', '') for l in leads if l.get('ramo_atividade'))))
|
||||||
|
ramo_sel = st.selectbox("Ramo:", ramos, key="tbl_ramo")
|
||||||
|
|
||||||
|
# Aplicar filtros
|
||||||
|
filtered = leads
|
||||||
|
if search:
|
||||||
|
s = search.lower()
|
||||||
|
filtered = [l for l in filtered if s in l.get('nome_empresa', '').lower() or s in l.get('telefone', '').lower() or s in l.get('notas', '').lower()]
|
||||||
|
if status_sel != "Todos":
|
||||||
|
filtered = [l for l in filtered if l.get('status_funil') == status_sel]
|
||||||
|
if cidade_sel != "Todas":
|
||||||
|
filtered = [l for l in filtered if l.get('cidade') == cidade_sel]
|
||||||
|
if ramo_sel != "Todos":
|
||||||
|
filtered = [l for l in filtered if l.get('ramo_atividade') == ramo_sel]
|
||||||
|
|
||||||
|
st.write(f"**Exibindo {len(filtered)} leads de {len(leads)} totais.**")
|
||||||
|
|
||||||
|
if filtered:
|
||||||
|
rows = []
|
||||||
|
for l in filtered:
|
||||||
|
tel_san = l.get('telefone_sanitizado', '')
|
||||||
|
wa_link = f"https://wa.me/{tel_san}" if tel_san else ""
|
||||||
|
rows.append({
|
||||||
|
'ID': l.get('id'),
|
||||||
|
'Empresa': l.get('nome_empresa'),
|
||||||
|
'Ramo': l.get('ramo_atividade'),
|
||||||
|
'Cidade/UF': f"{l.get('cidade', '')}/{l.get('uf', '')}",
|
||||||
|
'Telefone': l.get('telefone'),
|
||||||
|
'Rating': f"⭐ {l.get('google_rating')} ({l.get('total_avaliacoes')})",
|
||||||
|
'Status': l.get('status_funil'),
|
||||||
|
'WhatsApp': wa_link,
|
||||||
|
'Criado em': l.get('criado_em', '')[:10] if l.get('criado_em') else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
st.dataframe(
|
||||||
|
df,
|
||||||
|
column_config={
|
||||||
|
"WhatsApp": st.column_config.LinkColumn("WhatsApp Link", display_text="💬 Conversar")
|
||||||
|
},
|
||||||
|
use_container_width=True,
|
||||||
|
hide_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
st.markdown("### ⚙️ Ações Rápidas")
|
||||||
|
col_sel, col_act = st.columns([2, 1])
|
||||||
|
with col_sel:
|
||||||
|
selected_lead_id = st.selectbox(
|
||||||
|
"Selecione um Lead para Ação LGPD:",
|
||||||
|
options=[l['id'] for l in filtered],
|
||||||
|
format_func=lambda x: next((f"{l['nome_empresa']} ({l['status_funil']})" for l in filtered if l['id'] == x), x)
|
||||||
|
)
|
||||||
|
|
||||||
|
with col_act:
|
||||||
|
if st.button("🚫 Marcar Opt-Out (LGPD)", type="secondary", use_container_width=True):
|
||||||
|
if selected_lead_id:
|
||||||
|
api_client.opt_out_lead(selected_lead_id)
|
||||||
|
st.success("Lead marcado como Opt-Out (Excluído / LGPD).")
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.info("Nenhum lead encontrado com os filtros selecionados.")
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
from components.kanban import render_kanban_board
|
||||||
|
from components.metrics import render_metrics_summary
|
||||||
|
|
||||||
|
def render_crm_kanban_view(api_client: APIClient):
|
||||||
|
st.title("📌 Funil Commercial CRM (Kanban)")
|
||||||
|
|
||||||
|
# Carregar leads da API
|
||||||
|
leads = api_client.get_leads()
|
||||||
|
|
||||||
|
# Barra de Filtros
|
||||||
|
fc1, fc2, fc3 = st.columns([2, 1, 1])
|
||||||
|
with fc1:
|
||||||
|
search_query = st.text_input("🔍 Buscar por nome, telefone ou nota:", value="", key="kanban_search")
|
||||||
|
with fc2:
|
||||||
|
ramos = sorted(list(set(l.get('ramo_atividade', '') for l in leads if l.get('ramo_atividade'))))
|
||||||
|
selected_ramo = st.selectbox("Filtrar Ramo:", ["Todos"] + ramos, key="kanban_ramo")
|
||||||
|
with fc3:
|
||||||
|
cidades = sorted(list(set(l.get('cidade', '') for l in leads if l.get('cidade'))))
|
||||||
|
selected_cidade = st.selectbox("Filtrar Cidade:", ["Todas"] + cidades, key="kanban_cidade")
|
||||||
|
|
||||||
|
# Aplicar filtros locais
|
||||||
|
filtered_leads = leads
|
||||||
|
if search_query:
|
||||||
|
sq = search_query.lower()
|
||||||
|
filtered_leads = [
|
||||||
|
l for l in filtered_leads if
|
||||||
|
sq in l.get('nome_empresa', '').lower() or
|
||||||
|
sq in l.get('telefone', '').lower() or
|
||||||
|
sq in l.get('notas', '').lower()
|
||||||
|
]
|
||||||
|
if selected_ramo != "Todos":
|
||||||
|
filtered_leads = [l for l in filtered_leads if l.get('ramo_atividade') == selected_ramo]
|
||||||
|
if selected_cidade != "Todas":
|
||||||
|
filtered_leads = [l for l in filtered_leads if l.get('cidade') == selected_cidade]
|
||||||
|
|
||||||
|
# Exibir resumo de métricas KPI
|
||||||
|
render_metrics_summary(filtered_leads)
|
||||||
|
st.markdown("---")
|
||||||
|
|
||||||
|
# Exibir Modal de Detalhes se selecionado
|
||||||
|
selected_lead_id = st.session_state.get('selected_lead_id')
|
||||||
|
if selected_lead_id:
|
||||||
|
render_lead_detail_modal(selected_lead_id, api_client)
|
||||||
|
|
||||||
|
# Renderizar o Quadro Kanban
|
||||||
|
render_kanban_board(filtered_leads, api_client)
|
||||||
|
|
||||||
|
def render_lead_detail_modal(lead_id: str, api_client: APIClient):
|
||||||
|
lead_detail = api_client.get_lead_details(lead_id)
|
||||||
|
if not lead_detail:
|
||||||
|
st.session_state.pop('selected_lead_id', None)
|
||||||
|
return
|
||||||
|
|
||||||
|
with st.expander(f"📝 Detalhes e Histórico de Auditoria: {lead_detail.get('nome_empresa')}", expanded=True):
|
||||||
|
col1, col2 = st.columns([1, 1])
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("Informações do Lead")
|
||||||
|
st.write(f"**Ramo:** {lead_detail.get('ramo_atividade')}")
|
||||||
|
st.write(f"**Telefone:** {lead_detail.get('telefone', 'N/A')}")
|
||||||
|
st.write(f"**Endereço:** {lead_detail.get('logradouro', '')}, {lead_detail.get('bairro', '')} - {lead_detail.get('cidade', '')}/{lead_detail.get('uf', '')}")
|
||||||
|
st.write(f"**Status Atual:** `{lead_detail.get('status_funil')}`")
|
||||||
|
|
||||||
|
new_notes = st.text_area("Notas / Observações Comerciais:", value=lead_detail.get('notas', ''), height=120)
|
||||||
|
|
||||||
|
c_save, c_opt, c_close = st.columns([1, 1, 1])
|
||||||
|
with c_save:
|
||||||
|
if st.button("💾 Salvar Notas"):
|
||||||
|
api_client.update_lead(lead_id, {'notas': new_notes})
|
||||||
|
st.success("Notas atualizadas!")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with c_opt:
|
||||||
|
if st.button("🚫 Opt-Out (LGPD)", help="Marcar lead para não contatar"):
|
||||||
|
api_client.opt_out_lead(lead_id)
|
||||||
|
st.warning("Lead marcado como Opt-Out LGPD.")
|
||||||
|
st.session_state.pop('selected_lead_id', None)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with c_close:
|
||||||
|
if st.button("❌ Fechar"):
|
||||||
|
st.session_state.pop('selected_lead_id', None)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("📜 Histórico de Interações (Auditoria)")
|
||||||
|
interacoes = lead_detail.get('interacoes', [])
|
||||||
|
if not interacoes:
|
||||||
|
st.info("Nenhuma interação registrada ainda.")
|
||||||
|
else:
|
||||||
|
for idx in interacoes:
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
<div style="background: #21262D; border: 1px solid #30363D; border-radius: 6px; padding: 8px; margin-bottom: 6px; font-size: 0.85rem;">
|
||||||
|
<strong>[{idx.get('tipo')}]</strong> {idx.get('descricao')}<br>
|
||||||
|
<small style="color: #8B949E;">Por: {idx.get('usuario_nome')} em {idx.get('timestamp')[:19] if idx.get('timestamp') else ''}</small>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
def render_login_view(api_client: APIClient):
|
||||||
|
st.markdown("<br><br>", unsafe_allow_html=True)
|
||||||
|
c1, c2, c3 = st.columns([1, 2, 1])
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.markdown(
|
||||||
|
"""
|
||||||
|
<div style="text-align: center; margin-bottom: 24px;">
|
||||||
|
<h1 style="color: #58A6FF; margin-bottom: 4px;">📡 LeadRadar</h1>
|
||||||
|
<p style="color: #8B949E; font-size: 0.95rem;">Prospecção Inteligente & Gestão Comercial B2B</p>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
|
|
||||||
|
email = st.text_input("E-mail de Acesso", placeholder="seu.email@empresa.com", key="login_email")
|
||||||
|
password = st.text_input("Senha", type="password", placeholder="••••••••", key="login_password")
|
||||||
|
|
||||||
|
if st.button("🚀 Entrar no Sistema", use_container_width=True, type="primary"):
|
||||||
|
if not email or not password:
|
||||||
|
st.error("Por favor, preencha o e-mail e a senha.")
|
||||||
|
else:
|
||||||
|
with st.spinner("Autenticando credenciais..."):
|
||||||
|
success, message = api_client.login(email, password)
|
||||||
|
if success:
|
||||||
|
st.success(message)
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.error(message)
|
||||||
|
|
||||||
|
st.markdown(
|
||||||
|
"""
|
||||||
|
<div style="text-align: center; margin-top: 16px; font-size: 0.8rem; color: #8B949E;">
|
||||||
|
LeadRadar Core v1.0 • Desenvolvido com Flask & Streamlit
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
def render_profile_view(api_client: APIClient):
|
||||||
|
st.title("👤 Meu Perfil & Segurança")
|
||||||
|
|
||||||
|
user_info = st.session_state.get('user', {})
|
||||||
|
|
||||||
|
c1, c2 = st.columns([1, 1])
|
||||||
|
|
||||||
|
with c1:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("📋 Dados da Conta")
|
||||||
|
st.write(f"**Nome:** {user_info.get('nome', 'N/A')}")
|
||||||
|
st.write(f"**E-mail:** {user_info.get('email', 'N/A')}")
|
||||||
|
st.write(f"**Perfil:** `{user_info.get('role', 'user').upper()}`")
|
||||||
|
st.write(f"**Status da Conta:** {'🟢 Ativo' if user_info.get('ativo') else '🔴 Inativo'}")
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("🔒 Alterar Minha Senha")
|
||||||
|
old_pass = st.text_input("Senha Atual", type="password", key="pwd_old")
|
||||||
|
new_pass = st.text_input("Nova Senha (min 6 caracteres)", type="password", key="pwd_new")
|
||||||
|
confirm_pass = st.text_input("Confirmar Nova Senha", type="password", key="pwd_conf")
|
||||||
|
|
||||||
|
if st.button("🔑 Atualizar Senha", type="primary", use_container_width=True):
|
||||||
|
if not old_pass or not new_pass or not confirm_pass:
|
||||||
|
st.error("Preencha todos os campos de senha.")
|
||||||
|
elif new_pass != confirm_pass:
|
||||||
|
st.error("A nova senha e a confirmação não coincidem.")
|
||||||
|
elif len(new_pass) < 6:
|
||||||
|
st.error("A nova senha deve ter no mínimo 6 caracteres.")
|
||||||
|
else:
|
||||||
|
success, msg = api_client.change_password(old_pass, new_pass)
|
||||||
|
if success:
|
||||||
|
st.success("Senha alterada com sucesso! Faça login com sua nova senha.")
|
||||||
|
else:
|
||||||
|
st.error(msg)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
from api_client import APIClient
|
||||||
|
|
||||||
|
def render_radar_busca_view(api_client: APIClient):
|
||||||
|
st.title("📡 Radar de Busca & Prospecção Geolocalizada")
|
||||||
|
st.caption("Consulte estabelecimentos comerciais no Google Maps a partir da triangulação de CEP e ramo de atividade.")
|
||||||
|
|
||||||
|
c1, c2 = st.columns([1, 2])
|
||||||
|
|
||||||
|
with c1:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.subheader("🔍 Parâmetros de Varredura")
|
||||||
|
|
||||||
|
cep = st.text_input("CEP Alvo", value="01310-100", help="Digite o CEP com ou sem hífen (ex: 01310-100)")
|
||||||
|
|
||||||
|
ramo_sugestoes = [
|
||||||
|
"Clínica Odontológica", "Padaria", "Restaurante", "Academia",
|
||||||
|
"Escritório de Contabilidade", "Oficina Mecânica", "Farmácia", "Salão de Beleza"
|
||||||
|
]
|
||||||
|
ramo = st.selectbox("Ramo de Atividade", options=ramo_sugestoes, index=0)
|
||||||
|
custom_ramo = st.text_input("Ou digite um ramo personalizado:", value="", placeholder="Ex: Petshop 24h")
|
||||||
|
|
||||||
|
ramo_final = custom_ramo.strip() if custom_ramo.strip() else ramo
|
||||||
|
max_results = st.slider("Quantidade máxima de leads", min_value=5, max_value=50, value=15, step=5)
|
||||||
|
|
||||||
|
if st.button("🛰️ Disparar Prospecção Ativa", type="primary", use_container_width=True):
|
||||||
|
if not cep:
|
||||||
|
st.error("Informe um CEP válido.")
|
||||||
|
else:
|
||||||
|
progress_bar = st.progress(0, text="Iniciando triangulação de CEP via ViaCEP...")
|
||||||
|
|
||||||
|
with st.spinner("Extraindo estabelecimentos no Google Maps via Playwright Scraper..."):
|
||||||
|
progress_bar.progress(30, text="Bairro e Município resolvidos. Abrindo Playwright Chromium...")
|
||||||
|
success, data = api_client.search_maps(cep, ramo_final, max_results)
|
||||||
|
progress_bar.progress(80, text="Deduplicando e persistindo leads no banco de dados...")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
progress_bar.progress(100, text="Varredura concluída!")
|
||||||
|
st.session_state['last_search_data'] = data
|
||||||
|
st.success("Busca executada e leads atualizados no banco de dados!")
|
||||||
|
else:
|
||||||
|
progress_bar.empty()
|
||||||
|
st.error(data.get('error', 'Falha ao executar prospecção.'))
|
||||||
|
|
||||||
|
with c2:
|
||||||
|
search_data = st.session_state.get('last_search_data')
|
||||||
|
if search_data:
|
||||||
|
summary = search_data.get('summary', {})
|
||||||
|
loc = summary.get('location', {})
|
||||||
|
leads = summary.get('leads', [])
|
||||||
|
|
||||||
|
st.subheader("📊 Resultado da Varredura")
|
||||||
|
|
||||||
|
mc1, mc2, mc3 = st.columns(3)
|
||||||
|
mc1.metric("Localização", f"{loc.get('bairro', 'Bairro')}, {loc.get('cidade', 'Cidade')}-{loc.get('uf', '')}")
|
||||||
|
mc2.metric("Novos Leads", summary.get('created_count', 0))
|
||||||
|
mc3.metric("Reencontrados/Atualizados", summary.get('updated_count', 0))
|
||||||
|
|
||||||
|
if leads:
|
||||||
|
st.markdown("### 📋 Preview dos Leads Obtidos")
|
||||||
|
df_data = []
|
||||||
|
for l in leads:
|
||||||
|
df_data.append({
|
||||||
|
'Empresa': l.get('nome_empresa'),
|
||||||
|
'Ramo': l.get('ramo_atividade'),
|
||||||
|
'Telefone': l.get('telefone'),
|
||||||
|
'Avaliação': f"⭐ {l.get('google_rating')} ({l.get('total_avaliacoes')})",
|
||||||
|
'Bairro/Cidade': f"{l.get('bairro', '')} / {l.get('cidade', '')}",
|
||||||
|
'Status': l.get('status_funil')
|
||||||
|
})
|
||||||
|
df = pd.DataFrame(df_data)
|
||||||
|
st.dataframe(df, use_container_width=True)
|
||||||
|
st.info("💡 Acesse o menu **Funil CRM (Kanban)** para gerenciar estes leads!")
|
||||||
|
else:
|
||||||
|
st.info("👈 Preencha os parâmetros no painel ao lado e clique em **Disparar Prospecção Ativa** para iniciar.")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# LeadRadar - Especificação Técnica e Funcional de Arquitetura (specs.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão Geral do Produto
|
||||||
|
O **LeadRadar** é uma plataforma de automação para prospecção ativa de clientes (B2B/B2C) e gestão de funil comercial (Mini-CRM). A aplicação automatiza a busca de estabelecimentos comerciais no Google Maps a partir da triangulação de CEP e ramo de atividade, enriquecendo e organizando leads em um pipeline de vendas interativo.
|
||||||
|
|
||||||
|
O sistema é construído sobre uma arquitetura híbrida e desacoplada:
|
||||||
|
- **Backend API & Core Engine:** Python / Flask (REST API, persistência, controle de acesso e regras de negócio).
|
||||||
|
- **Frontend & Dashboard Operacional:** Streamlit com componentes customizados em CSS/HTML para proporcionar uma interface responsiva, com identidade visual sóbria, moderna (*dark slate / deep tech*) e de usabilidade fluida.
|
||||||
|
- **Integração Externa:** Endpoints e Webhooks preparados para orquestração assíncrona via **n8n** (para futuros fluxos de envio e recepção de mensagens).
|
||||||
|
- **Containerização:** Docker & Docker Compose com isolamento de rede e persistência por volumes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitetura do Sistema e Stack Tecnológica
|
||||||
|
|
||||||
|
### 2.1 Componentes Principais
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ LeadRadar UI │
|
||||||
|
│ (Streamlit - Frontend Moderno / Responsivo) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ HTTP / REST (JWT Bearer)
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ LeadRadar Core │
|
||||||
|
│ (Flask Application Service) │
|
||||||
|
│ ┌─────────────────┬───────────────────┬─────────────────┐ │
|
||||||
|
│ │ Auth & RBAC │ Maps Scraper │ Webhook API │ │
|
||||||
|
│ │ (Flask-JWT-Ext) │ (Playwright Core) │ (Para n8n) │ │
|
||||||
|
│ └────────┬────────┴─────────┬─────────┴────────┬────────┘ │
|
||||||
|
└───────────┼──────────────────┼──────────────────┼───────────┘
|
||||||
|
│ │ │
|
||||||
|
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
|
||||||
|
│ SQLite / PG │ │ ViaCEP & │ │ Fluxo n8n │
|
||||||
|
│ (SQLAlchemy) │ │ OSM Nominatim │ │ (Mensageria) │
|
||||||
|
└─────────────────┘ └────────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Tecnologias Utilizadas
|
||||||
|
- **Linguagem:** Python 3.11+
|
||||||
|
- **Backend Framework:** Flask 3.x, Flask-SQLAlchemy, Flask-Bcrypt, Flask-JWT-Extended, Pydantic (validação).
|
||||||
|
- **Frontend Framework:** Streamlit 1.35+ (com `st_shadcn_ui` / CSS Injection para tema dark tech sóbrio).
|
||||||
|
- **Scraping & Geocoding:** Playwright (Chromium headless), `httpx` / `requests`, APIs de geocodificação (ViaCEP + OpenStreetMap Nominatim).
|
||||||
|
- **Banco de Dados:** PostgreSQL 16 (produção em Docker) / SQLite com WAL mode (desenvolvimento/testes).
|
||||||
|
- **Testes & Qualidade:** Pytest, Pytest-Flask, Pytest-Playwright, Coverage, Black, Flake8, Bandit (SAST).
|
||||||
|
- **Containerização:** Docker (Multi-stage build) & Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Controle de Acesso e Autenticação (RBAC)
|
||||||
|
|
||||||
|
### 3.1 Perfis de Acesso
|
||||||
|
| Funcionalidade / Permissão | Perfil `admin` | Perfil `user` |
|
||||||
|
| :--- | :---: | :---: |
|
||||||
|
| Executar busca de leads no Maps (CEP + Ramo) | Sim | Sim |
|
||||||
|
| Visualizar e movimentar leads no Kanban | Sim | Sim |
|
||||||
|
| Editar notas, etiquetas e contatos de leads | Sim | Sim |
|
||||||
|
| Exportar leads em CSV / JSON | Sim | Sim |
|
||||||
|
| Alterar a própria senha | Sim | Sim |
|
||||||
|
| Criar novos usuários | Sim | **Não** |
|
||||||
|
| Excluir ou bloquear usuários | Sim | **Não** |
|
||||||
|
| Resetar senha de terceiros | Sim | **Não** |
|
||||||
|
| Limpeza / Expurgar banco de dados e logs | Sim | **Não** |
|
||||||
|
| Configurar webhooks e credenciais de integração | Sim | **Não** |
|
||||||
|
|
||||||
|
### 3.2 Segurança de Autenticação
|
||||||
|
- Armazenamento de senhas utilizando hash seguro via **Bcrypt** com fator de custo (rounds) $\ge 12$.
|
||||||
|
- Sessões autenticadas via **JWT (JSON Web Tokens)** trafegados no cabeçalho `Authorization: Bearer <token>` ou cookies HTTP-Only seguros com tempo de expiração (`access_token`: 8 horas, `refresh_token`: 7 dias).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modelagem de Dados
|
||||||
|
|
||||||
|
### 4.1 Entidade `users`
|
||||||
|
- `id` (UUID / Integer PK)
|
||||||
|
- `nome` (VARCHAR(120), Not Null)
|
||||||
|
- `email` (VARCHAR(180), Unique, Indexed, Not Null)
|
||||||
|
- `password_hash` (VARCHAR(255), Not Null)
|
||||||
|
- `role` (ENUM: `'admin'`, `'user'`, Default: `'user'`)
|
||||||
|
- `ativo` (BOOLEAN, Default: `True`)
|
||||||
|
- `criado_em` (DATETIME, Default: UTC Now)
|
||||||
|
- `atualizado_em` (DATETIME, On Update: UTC Now)
|
||||||
|
|
||||||
|
### 4.2 Entidade `leads`
|
||||||
|
- `id` (UUID / Integer PK)
|
||||||
|
- `nome_empresa` (VARCHAR(255), Indexed, Not Null)
|
||||||
|
- `ramo_atividade` (VARCHAR(100), Indexed, Not Null)
|
||||||
|
- `cep_busca` (VARCHAR(10), Indexed, Not Null)
|
||||||
|
- `logradouro` (VARCHAR(255))
|
||||||
|
- `bairro` (VARCHAR(100))
|
||||||
|
- `cidade` (VARCHAR(100), Indexed)
|
||||||
|
- `uf` (VARCHAR(2))
|
||||||
|
- `telefone` (VARCHAR(50))
|
||||||
|
- `telefone_sanitizado` (VARCHAR(30), Indexed) # Formato E.164 (ex: 5511999999999)
|
||||||
|
- `whatsapp_valido` (BOOLEAN, Nullable)
|
||||||
|
- `website` (VARCHAR(255))
|
||||||
|
- `google_rating` (FLOAT, Default: 0.0)
|
||||||
|
- `total_avaliacoes` (INTEGER, Default: 0)
|
||||||
|
- `google_maps_url` (TEXT)
|
||||||
|
- `status_funil` (ENUM: `'novo'`, `'contatado'`, `'respondeu'`, `'negociacao'`, `'ganho'`, `'perdido'`)
|
||||||
|
- `tags` (JSON / Array de tags customizadas)
|
||||||
|
- `notas` (TEXT)
|
||||||
|
- `usuario_responsavel_id` (FK -> users.id, Nullable)
|
||||||
|
- `criado_em` (DATETIME, Default: UTC Now)
|
||||||
|
- `atualizado_em` (DATETIME, On Update: UTC Now)
|
||||||
|
|
||||||
|
### 4.3 Entidade `lead_interacoes` (Histórico & Auditoria)
|
||||||
|
- `id` (UUID / Integer PK)
|
||||||
|
- `lead_id` (FK -> leads.id, On Delete Cascade)
|
||||||
|
- `usuario_id` (FK -> users.id, Nullable)
|
||||||
|
- `tipo` (ENUM: `'status_change'`, `'nota_adicionada'`, `'mensagem_enviada'`, `'webhook_n8n'`)
|
||||||
|
- `descricao` (TEXT)
|
||||||
|
- `metadados` (JSONB / TEXT)
|
||||||
|
- `timestamp` (DATETIME, Default: UTC Now)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Módulo de Automação & Busca de Leads
|
||||||
|
|
||||||
|
### 5.1 Fluxo de Triangulação de CEP
|
||||||
|
1. Usuário informa o **CEP** (ex: `01310-100`) e o **Ramo de Atuação** (ex: `Clínica Odontológica`, `Padaria`, `Restaurante`).
|
||||||
|
2. O sistema consome a API do **ViaCEP** (`https://viacep.com.br/ws/{cep}/json/`) para obter bairro, município e UF.
|
||||||
|
3. Se necessário, resolve a latitude/longitude via **Nominatim OpenStreetMap** ou monta a string de busca contextualizada: `"{ramo} em {bairro}, {municipio} - {uf}"`.
|
||||||
|
4. O scraper do Google Maps (Playwright) abre a busca com rolagem dinâmica e extrai os metadados dos estabelecimentos.
|
||||||
|
|
||||||
|
### 5.2 Regras de Deduplicação
|
||||||
|
- Deduplicação automática baseada na combinação de `(nome_empresa, telefone_sanitizado, cidade)`.
|
||||||
|
- Se o lead já existir na base, atualiza apenas dados cadastrais se houver novos campos (sem resetar o `status_funil`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Interface Visual e Experiência do Usuário (UI/UX)
|
||||||
|
|
||||||
|
### 6.1 Diretrizes de Design
|
||||||
|
- **Paleta de Cores:** Fundo escuro grafite/ardósia (`#0D1117`, `#161B22`), bordas sutis (`#30363D`), tipografia com alto contraste e legibilidade (`#E6EDF3`, `#8B949E`), e acentos em azul tecnológico (`#1F6FEB`, `#58A6FF`).
|
||||||
|
- **Navegação Modular:**
|
||||||
|
- **Radar de Busca:** Formulário limpo com validação de CEP, seleção de ramo e indicador visual de progresso da varredura.
|
||||||
|
- **Funil CRM (Kanban):** Colunas interativas com cards compactos, tags coloridas por ramo, nota do Google e atalho rápido para WhatsApp/edição de status.
|
||||||
|
- **Tabela Analítica / Exportação:** Visualização tabular com filtros avançados, ordenação e exportação em CSV/XLSX.
|
||||||
|
- **Administração:** Painel exclusivo para administradores com criação/exclusão de operadores, logs de auditoria e configurações.
|
||||||
|
- **Perfil / Segurança:** Modal ou tela para alteração segura de senha pelo próprio usuário logado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Conformidade com a LGPD e Segurança da Informação
|
||||||
|
|
||||||
|
1. **Princípio da Finalidade e Necessidade (Art. 6º, I e III):**
|
||||||
|
- Coleta restrita a dados comerciais publicamente disponibilizados pelos próprios estabelecimentos comerciais no Google Maps para fins legítimos de prospecção B2B (Art. 7º, IX - Legítimo Interesse).
|
||||||
|
2. **Direito de Eliminação / Opt-Out (Art. 18, VI):**
|
||||||
|
- Mecanismo integrado para marcar leads como "Não Contatar / Excluído", garantindo que novos disparos ou re-importações não reativem o contato.
|
||||||
|
3. **Auditoria e Rastreabilidade:**
|
||||||
|
- Log de todas as ações sensíveis (exportações, exclusões de leads, cadastros de usuários) com timestamp e ID do usuário executor.
|
||||||
|
4. **Segurança de Dados em Trânsito e Repouso:**
|
||||||
|
- Comunicação interna e externa sob TLS/HTTPS em ambiente de produção; senhas com salt/hashing irreversível.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Arquitetura Docker & Deployment
|
||||||
|
|
||||||
|
A aplicação deve conter:
|
||||||
|
- `Dockerfile.backend`: Multi-stage build otimizado para a API Flask + Playwright (instalando dependências do Chromium headless).
|
||||||
|
- `Dockerfile.frontend`: Imagem leve para o Streamlit.
|
||||||
|
- `docker-compose.yml`: Orquestração contendo os serviços:
|
||||||
|
- `leadradar-db` (PostgreSQL 16 com volume persistente).
|
||||||
|
- `leadradar-api` (Flask Backend).
|
||||||
|
- `leadradar-ui` (Streamlit Frontend).
|
||||||
|
- Arquivo `.env.example` estruturado com segredos (`JWT_SECRET_KEY`, `POSTGRES_PASSWORD`, `FLASK_ENV`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Plano de Testes e Garantia de Qualidade
|
||||||
|
|
||||||
|
1. **Testes Unitários:**
|
||||||
|
- Validação de funções de formatação/sanitização de telefone e CEP.
|
||||||
|
- Hashing e validação de senhas com Bcrypt.
|
||||||
|
- Verificação de políticas RBAC (permissões de Admin vs User).
|
||||||
|
2. **Testes de Integração:**
|
||||||
|
- Endpoints de autenticação (`/api/v1/auth/login`, `/api/v1/auth/change-password`).
|
||||||
|
- CRUD de usuários (garantindo que `user` receba `403 Forbidden` ao tentar criar/deletar).
|
||||||
|
- Inserção e atualização de leads e interações.
|
||||||
|
3. **Testes End-to-End (E2E):**
|
||||||
|
- Mock e execução do crawler de busca geolocalizada via Playwright.
|
||||||
|
- Fluxo completo de busca -> salvamento no banco -> movimentação no Kanban.
|
||||||
Reference in New Issue
Block a user