feat: implementacao inicial do sistema LeadRadar
This commit is contained in:
@@ -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'
|
||||
Reference in New Issue
Block a user