Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
DB_USER=leadscraper
|
||||||
|
DB_PASSWORD=troque-esta-senha
|
||||||
|
DB_PORT=5433
|
||||||
|
BACKEND_PORT=8082
|
||||||
|
FRONTEND_PORT=8081
|
||||||
|
SERVER_PORT=8082
|
||||||
|
|
||||||
|
JWT_SECRET=troque-por-uma-chave-com-pelo-menos-32-caracteres
|
||||||
|
JWT_EXPIRATION_MINUTES=480
|
||||||
|
API_URL=/api
|
||||||
|
|
||||||
|
BUSCA_WORKER_ENABLED=true
|
||||||
|
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||||
|
SCRAPER_HEADLESS=true
|
||||||
|
SCRAPER_TIMEOUT_MS=300000
|
||||||
|
SCRAPER_MAX_RESULTS=1000
|
||||||
|
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||||
|
SCRAPER_DELAY_MIN_MS=900
|
||||||
|
SCRAPER_DELAY_MAX_MS=2500
|
||||||
|
OVERPASS_ENABLED=true
|
||||||
|
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||||
|
OVERPASS_TIMEOUT_SECONDS=90
|
||||||
|
OVERPASS_MAX_RESULTS=1000
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
DB_USER=leadscraper
|
||||||
|
DB_PASSWORD=troque-esta-senha
|
||||||
|
DB_PORT=5433
|
||||||
|
BACKEND_PORT=8082
|
||||||
|
FRONTEND_PORT=8081
|
||||||
|
SERVER_PORT=8082
|
||||||
|
|
||||||
|
JWT_SECRET=troque-por-uma-chave-com-pelo-menos-32-caracteres
|
||||||
|
JWT_EXPIRATION_MINUTES=480
|
||||||
|
API_URL=/api
|
||||||
|
APP_PUBLIC_URL=https://escavador.seconectai.com.br
|
||||||
|
|
||||||
|
BUSCA_WORKER_ENABLED=true
|
||||||
|
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||||
|
SCRAPER_HEADLESS=true
|
||||||
|
SCRAPER_TIMEOUT_MS=300000
|
||||||
|
SCRAPER_MAX_RESULTS=1000
|
||||||
|
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||||
|
SCRAPER_DELAY_MIN_MS=900
|
||||||
|
SCRAPER_DELAY_MAX_MS=2500
|
||||||
|
OVERPASS_ENABLED=true
|
||||||
|
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||||
|
OVERPASS_TIMEOUT_SECONDS=90
|
||||||
|
OVERPASS_MAX_RESULTS=1000
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
target/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.log
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
escavador.seconectai.com.br {
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Deploy em escavador.seconectai.com.br
|
||||||
|
|
||||||
|
Dominio: `escavador.seconectai.com.br`
|
||||||
|
|
||||||
|
Antes da instalacao, aponte o DNS do dominio para o IP do servidor.
|
||||||
|
|
||||||
|
## Instalar comandos globais
|
||||||
|
|
||||||
|
Via Gitea:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts/bootstrap-commands.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou, se o repositorio ja estiver no servidor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install-commands.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Primeira instalacao
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seconectaiescavador.install
|
||||||
|
```
|
||||||
|
|
||||||
|
O comando clona ou atualiza o codigo em `/opt/Prospeccao_SeConectai`, gera o `.env`, constroi as imagens Docker e sobe os containers com HTTPS pelo Caddy.
|
||||||
|
|
||||||
|
URL final:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://escavador.seconectai.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
## Atualizar
|
||||||
|
|
||||||
|
Depois de novos commits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seconectaiescavador.update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos uteis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml ps
|
||||||
|
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f backend
|
||||||
|
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f frontend
|
||||||
|
docker compose -f /opt/Prospeccao_SeConectai/docker-compose.prod.yml logs -f caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Postgres nao fica publicado na internet no compose de producao.
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
FROM maven:3.9.9-eclipse-temurin-21 AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn -B -DskipTests dependency:go-offline
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -B -DskipTests package
|
||||||
|
RUN mvn -B exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre-jammy AS runtime
|
||||||
|
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/ms-playwright/chromium-1140/chrome-linux/chrome
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libexpat1 \
|
||||||
|
libgbm1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libx11-6 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxshmfence1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /root/.cache/ms-playwright /ms-playwright
|
||||||
|
COPY --from=build /app/target/leadscraper-0.0.1-SNAPSHOT.jar app.jar
|
||||||
|
|
||||||
|
EXPOSE 8082
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# LeadScraper — Arquitetura e Schema (v1.1)
|
||||||
|
|
||||||
|
Documento de arquitetura de referência. Qualquer mudança de schema ou de stack deve ser refletida aqui primeiro (versionar para v1.1, v1.2...) antes de alterar código.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Stack tecnológica
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Java 21**
|
||||||
|
- **Spring Boot 3.x** — REST API, injeção de dependência, configuração
|
||||||
|
- **Spring Data JPA + Hibernate** — persistência
|
||||||
|
- **Flyway** — migrations versionadas do banco
|
||||||
|
- **PostgreSQL 16** — banco relacional principal
|
||||||
|
- **Playwright for Java** — automação de navegador (Chromium headless) para o scraping do Google Maps
|
||||||
|
- **Spring Security + JWT** — autenticação da API (dashboard interno, não público)
|
||||||
|
- **Bean Validation (Jakarta Validation)** — validação de entrada (CNPJ, telefone, etc.)
|
||||||
|
- **Lombok** — redução de boilerplate
|
||||||
|
- **Maven** — build e gerenciamento de dependências
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 18 + Vite + TypeScript**
|
||||||
|
- **Tailwind CSS + shadcn/ui** — componentes e estilo
|
||||||
|
- **TanStack Query** — cache e sincronização com a API
|
||||||
|
- **React Hook Form + Zod** — formulários e validação
|
||||||
|
- **Recharts** — gráficos simples (ex: leads por ramo, buscas por dia)
|
||||||
|
|
||||||
|
### Infraestrutura
|
||||||
|
- **Docker + Docker Compose** — orquestração de todos os serviços
|
||||||
|
- **Nginx** — servir o build estático do frontend dentro do container
|
||||||
|
- **Volumes Docker** — persistência de dados do PostgreSQL entre reinicializações
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitetura em módulos (backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
com.seconectai.leadscraper
|
||||||
|
├── config/ → segurança, CORS, configuração do Playwright
|
||||||
|
├── domain/ → entidades JPA (Lead, Ramo, Busca, Proxy, Telefone, HistoricoContato)
|
||||||
|
├── repository/ → interfaces Spring Data JPA
|
||||||
|
├── scraper/ → motor de automação do navegador (busca, scroll, extração bruta)
|
||||||
|
├── normalizer/ → padronização de nome, telefone, CNPJ, ramo
|
||||||
|
├── proxy/ → gestão do pool de proxies, rotação, backoff
|
||||||
|
├── service/ → orquestração das regras de negócio
|
||||||
|
├── controller/ → endpoints REST
|
||||||
|
├── security/ → autenticação JWT
|
||||||
|
└── worker/ → processamento assíncrono das buscas em fila (status pendente → em_execucao → concluida)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Fluxo de execução de uma busca
|
||||||
|
|
||||||
|
1. Usuário informa `termo_busca` + `localizacao` pelo frontend
|
||||||
|
2. Backend cria um registro em `buscas` com status `pendente`
|
||||||
|
3. `worker` pega a busca pendente, muda status para `em_execucao`, seleciona um proxy disponível no pool
|
||||||
|
4. `scraper` abre o navegador com o proxy configurado, executa a busca no Maps, faz scroll gradual com delays humanizados até esgotar os resultados, extrai dados brutos de cada card
|
||||||
|
5. `normalizer` padroniza cada lead extraído (nome, telefone, CNPJ, ramo) e calcula `hash_dedup`
|
||||||
|
6. `service` verifica duplicidade (por `hash_dedup` e `place_id`); se já existe, atualiza vínculo com a nova busca em vez de duplicar
|
||||||
|
7. Leads novos são persistidos; `busca.total_encontrados` é atualizado; status muda para `concluida` (ou `erro` se algo falhar, com detalhe do erro registrado)
|
||||||
|
8. Frontend consulta o status via polling (TanStack Query) e exibe os leads assim que disponíveis
|
||||||
|
|
||||||
|
## 4. Schema do banco de dados
|
||||||
|
|
||||||
|
### 4.1 `usuarios` — acesso ao dashboard
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE usuarios (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
nome VARCHAR(150) NOT NULL,
|
||||||
|
email VARCHAR(150) NOT NULL UNIQUE,
|
||||||
|
senha_hash VARCHAR(255) NOT NULL,
|
||||||
|
papel VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | operador
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `ramos` — taxonomia normalizada de categorias
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE ramos (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
nome_canonico VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
categoria_pai_id BIGINT REFERENCES ramos(id),
|
||||||
|
aliases JSONB NOT NULL DEFAULT '[]',
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `proxies` — pool de proxies para rotação
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE proxies (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
endereco VARCHAR(255) NOT NULL,
|
||||||
|
tipo VARCHAR(20) DEFAULT 'residencial', -- residencial | datacenter
|
||||||
|
status VARCHAR(20) DEFAULT 'ativo', -- ativo | degradado | bloqueado
|
||||||
|
total_bloqueios INTEGER DEFAULT 0,
|
||||||
|
em_uso BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ultima_utilizacao TIMESTAMPTZ,
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `buscas` — cada execução do motor de busca
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE buscas (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
termo_busca VARCHAR(150) NOT NULL,
|
||||||
|
localizacao VARCHAR(150) NOT NULL,
|
||||||
|
ramo_id BIGINT REFERENCES ramos(id),
|
||||||
|
proxy_id BIGINT REFERENCES proxies(id),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pendente', -- pendente | em_execucao | concluida | erro
|
||||||
|
total_encontrados INTEGER DEFAULT 0,
|
||||||
|
detalhe_erro TEXT,
|
||||||
|
iniciado_em TIMESTAMPTZ,
|
||||||
|
finalizado_em TIMESTAMPTZ,
|
||||||
|
criado_por BIGINT REFERENCES usuarios(id),
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 `leads` — tabela principal
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE leads (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
place_id VARCHAR(150),
|
||||||
|
razao_social VARCHAR(200),
|
||||||
|
nome_fantasia VARCHAR(200) NOT NULL,
|
||||||
|
cnpj CHAR(14),
|
||||||
|
telefone_e164 VARCHAR(20),
|
||||||
|
telefone_bruto VARCHAR(40),
|
||||||
|
email VARCHAR(150),
|
||||||
|
site VARCHAR(255),
|
||||||
|
endereco_completo TEXT,
|
||||||
|
logradouro VARCHAR(200),
|
||||||
|
numero VARCHAR(20),
|
||||||
|
bairro VARCHAR(100),
|
||||||
|
cidade VARCHAR(100),
|
||||||
|
uf CHAR(2),
|
||||||
|
cep CHAR(8),
|
||||||
|
latitude NUMERIC(10,7),
|
||||||
|
longitude NUMERIC(10,7),
|
||||||
|
ramo_id BIGINT REFERENCES ramos(id),
|
||||||
|
categoria_bruta VARCHAR(150),
|
||||||
|
fonte VARCHAR(30) NOT NULL DEFAULT 'google_maps',
|
||||||
|
busca_id BIGINT REFERENCES buscas(id),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'novo', -- novo | contatado | qualificado | convertido | descartado
|
||||||
|
hash_dedup VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_leads_ramo ON leads(ramo_id);
|
||||||
|
CREATE INDEX idx_leads_cidade_uf ON leads(cidade, uf);
|
||||||
|
CREATE INDEX idx_leads_busca ON leads(busca_id);
|
||||||
|
CREATE UNIQUE INDEX idx_leads_cnpj_unique ON leads(cnpj) WHERE cnpj IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 `telefones` (opcional — múltiplos telefones por lead)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE telefones (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
numero_e164 VARCHAR(20) NOT NULL,
|
||||||
|
tipo VARCHAR(20) DEFAULT 'principal',
|
||||||
|
UNIQUE (lead_id, numero_e164)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 `historico_contato` — pipeline de acompanhamento
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE historico_contato (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
canal VARCHAR(30), -- whatsapp | ligacao | email
|
||||||
|
resultado VARCHAR(50),
|
||||||
|
observacao TEXT,
|
||||||
|
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Regras de padronização (resumo — detalhado na etapa 04)
|
||||||
|
|
||||||
|
- **Nome:** trim, colapsar espaços, Title Case preservando siglas societárias (LTDA, ME, EIRELI, S/A)
|
||||||
|
- **Telefone:** apenas dígitos, assumir DDI +55 se ausente, formato final E.164, manter bruto para auditoria
|
||||||
|
- **CNPJ:** apenas dígitos, validar dígito verificador (módulo 11) antes de gravar
|
||||||
|
- **Ramo:** nunca gravar categoria bruta como definitiva — mapear via tabela `ramos.aliases`; sem mapeamento conhecido, `ramo_id = NULL` e categoria fica em `categoria_bruta` para curadoria manual
|
||||||
|
- **Deduplicação:** `hash_dedup = SHA-256(nome_normalizado + '|' + telefone_e164)`; `place_id` como critério secundário
|
||||||
|
|
||||||
|
## 6. Anti-bloqueio (resumo — detalhado na etapa 05)
|
||||||
|
|
||||||
|
- Pool de proxies residenciais com rotação por sessão de busca (não por requisição)
|
||||||
|
- Delays humanizados aleatórios entre ações e entre buscas
|
||||||
|
- Scroll gradual em passos pequenos, não pulo direto
|
||||||
|
- Fingerprint variável (User-Agent, viewport, timezone) por contexto de navegador
|
||||||
|
- Detecção de captcha/bloqueio com backoff exponencial e desativação temporária do proxy afetado
|
||||||
|
- Limite de volume auto-imposto por proxy/hora
|
||||||
|
|
||||||
|
## 7. Docker Compose — visão geral dos serviços
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres: # banco de dados
|
||||||
|
backend: # API Spring Boot + motor de scraping
|
||||||
|
frontend: # build React servido via Nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Detalhes completos do `docker-compose.yml` estão na etapa 09.
|
||||||
|
|
||||||
|
## 8. Histórico de versões deste documento
|
||||||
|
|
||||||
|
- **v1.1** (atual) — adiciona `proxies.em_uso` para reserva concorrente do pool de proxies na etapa 05
|
||||||
|
- **v1.0** — schema inicial completo: leads, ramos, buscas, proxies, telefones, historico_contato, usuarios; stack backend/frontend/infra definida
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# LeadScraper — Leia-me
|
||||||
|
|
||||||
|
Motor de busca e prospecção de leads via automação de navegador (Google Maps), com backend em Java, frontend próprio, banco de dados PostgreSQL e execução em Docker.
|
||||||
|
|
||||||
|
Este documento é o ponto de entrada do projeto. Leia-o antes de começar qualquer etapa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Estrutura do projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
leadscraper/
|
||||||
|
├── LEIA-ME.md ← este arquivo
|
||||||
|
├── ARQUITETURA-SCHEMA.md ← schema completo do banco + stack tecnológica + módulos
|
||||||
|
├── PROGRESSO.md ← tracker de progresso, atualizado a cada etapa concluída
|
||||||
|
└── etapas/
|
||||||
|
├── 01-setup-projeto-docker-flyway.md
|
||||||
|
├── 02-modelagem-entidades-jpa.md
|
||||||
|
├── 03-motor-scraper-playwright.md
|
||||||
|
├── 04-normalizador-padronizacao.md
|
||||||
|
├── 05-pool-proxies-anti-bloqueio.md
|
||||||
|
├── 06-api-rest-backend.md
|
||||||
|
├── 07-frontend-setup-dashboard.md
|
||||||
|
├── 08-frontend-integracao-api.md
|
||||||
|
├── 09-docker-compose-deploy.md
|
||||||
|
└── 10-testes-validacao-final.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Como este projeto deve ser executado
|
||||||
|
|
||||||
|
1. **Leia `ARQUITETURA-SCHEMA.md` por completo** antes de iniciar qualquer etapa — ele é a fonte de verdade sobre schema de banco, tecnologias e módulos. Nenhuma etapa deve divergir do que está lá sem atualizar o documento primeiro.
|
||||||
|
2. **Siga as etapas em `etapas/` na ordem numérica.** Cada arquivo é autocontido: traz objetivo, contexto necessário e critérios de aceite. Não pule etapas — cada uma assume que a anterior foi concluída.
|
||||||
|
3. **Antes de iniciar uma etapa, abra `PROGRESSO.md`** e confira se a etapa anterior está marcada como concluída. Se não estiver, resolva pendências antes de avançar.
|
||||||
|
4. **Ao concluir uma etapa, atualize `PROGRESSO.md` imediatamente** — marque status, data e qualquer observação relevante (ex: decisão técnica tomada, desvio do plano original).
|
||||||
|
5. Se durante a execução de uma etapa for necessário mudar algo do schema ou da arquitetura definida em `ARQUITETURA-SCHEMA.md`, **atualize o documento primeiro** (versionando, ex: v1.0 → v1.1) e só depois prossiga — nunca deixe o código divergir silenciosamente do documento de arquitetura.
|
||||||
|
6. Novas funcionalidades que surgirem depois do projeto em produção (ex: exportação avançada, novo canal de contato) devem virar **novos arquivos de etapa numerados** (11, 12...) ou migrations incrementais — nunca reescrever o schema base do zero.
|
||||||
|
|
||||||
|
## 3. Pré-requisitos de ambiente
|
||||||
|
|
||||||
|
- Java 21 (JDK)
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Node.js 20+ (apenas para build do frontend)
|
||||||
|
- Maven (ou o wrapper `mvnw` incluso no projeto backend)
|
||||||
|
|
||||||
|
## 4. Visão geral rápida do sistema
|
||||||
|
|
||||||
|
- **Backend (Java/Spring Boot):** expõe API REST, roda o motor de scraping (Playwright), aplica as regras de padronização e persiste os leads.
|
||||||
|
- **Frontend (React/Vite):** dashboard para disparar novas buscas, acompanhar status, listar/filtrar/exportar leads.
|
||||||
|
- **Banco (PostgreSQL):** armazena leads, ramos, buscas, proxies e histórico de contato.
|
||||||
|
- **Docker Compose:** orquestra os três serviços acima para subir o ambiente completo com um único comando.
|
||||||
|
|
||||||
|
Detalhes completos de cada peça estão em `ARQUITETURA-SCHEMA.md`.
|
||||||
|
|
||||||
|
## 5. Subida completa com Docker Compose
|
||||||
|
|
||||||
|
1. Crie um `.env` a partir do exemplo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edite o `.env` antes de usar em produção:
|
||||||
|
|
||||||
|
- `DB_PASSWORD`: use uma senha forte.
|
||||||
|
- `JWT_SECRET`: use uma chave longa, com pelo menos 32 caracteres.
|
||||||
|
- `API_URL`: mantenha `/api` quando o frontend Nginx fizer proxy para o backend no mesmo compose. Para publicar backend e frontend em domínios separados, use a URL pública do backend, por exemplo `https://api.seudominio.com`.
|
||||||
|
- `FRONTEND_PORT`, `BACKEND_PORT`, `SERVER_PORT` e `DB_PORT`: ajuste se as portas `8081`, `8082` ou `5433` já estiverem ocupadas.
|
||||||
|
- `BUSCA_WORKER_ENABLED`: use `false` se quiser subir o ambiente sem processar buscas automaticamente.
|
||||||
|
|
||||||
|
3. Suba o ambiente completo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
O Compose sobe PostgreSQL, backend e frontend. O backend espera o healthcheck do banco, aplica as migrations do Flyway no startup e expõe a API em `http://localhost:8082` por padrão. O frontend fica em `http://localhost:8081` por padrão e encaminha chamadas `/api/*` para o backend interno.
|
||||||
|
|
||||||
|
Se você alterar `DB_PASSWORD` depois que o volume `postgres_data` já foi criado, o Postgres continuará usando a senha definida na primeira inicialização. Para trocar a senha em ambiente local descartável, recrie o volume; em produção, faça a rotação de senha pelo próprio banco antes de atualizar o `.env`.
|
||||||
|
|
||||||
|
## 6. Deploy em produção
|
||||||
|
|
||||||
|
O deploy oficial usa `escavador.seconectai.com.br`, Caddy para HTTPS automático e `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
Para instalar os comandos globais no servidor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts/bootstrap-commands.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Primeira instalação:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seconectaiescavador.install
|
||||||
|
```
|
||||||
|
|
||||||
|
Atualização depois de novos commits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
seconectaiescavador.update
|
||||||
|
```
|
||||||
|
|
||||||
|
Veja detalhes em `DEPLOY.md`.
|
||||||
|
|
||||||
|
Credenciais iniciais de desenvolvimento:
|
||||||
|
|
||||||
|
- Email: `tiago27.dossantos@gmail.com`
|
||||||
|
- Senha: `10031992`
|
||||||
|
|
||||||
|
## 7. Ordem de leitura recomendada para quem for executar
|
||||||
|
|
||||||
|
1. `LEIA-ME.md` (este arquivo)
|
||||||
|
2. `ARQUITETURA-SCHEMA.md`
|
||||||
|
3. `PROGRESSO.md` (ver o que já foi feito, se houver histórico)
|
||||||
|
4. `etapas/01-...md` em diante, sequencialmente
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# LeadScraper — Progresso do Projeto
|
||||||
|
|
||||||
|
**Instrução para quem estiver executando:** antes de iniciar uma etapa, confira aqui se a etapa anterior está `Concluída`. Ao terminar uma etapa, atualize a linha correspondente imediatamente — não deixe para atualizar em lote no final.
|
||||||
|
|
||||||
|
Status possíveis: `Não iniciada` | `Em andamento` | `Concluída` | `Bloqueada`
|
||||||
|
|
||||||
|
| # | Etapa | Arquivo | Status | Data conclusão | Observações |
|
||||||
|
|---|-------|---------|--------|-----------------|-------------|
|
||||||
|
| 01 | Setup do projeto, Docker e Flyway | `etapas/01-setup-projeto-docker-flyway.md` | Concluída | 2026-09-21 | Esqueleto Spring Boot, Docker Compose Postgres, application.yml, Flyway V1 e pacotes base criados. `mvn test` passou; Postgres subiu via Docker Compose; aplicação conectou e registrou `V1__init` em `flyway_schema_history`. |
|
||||||
|
| 02 | Modelagem das entidades JPA | `etapas/02-modelagem-entidades-jpa.md` | Concluída | 2026-09-21 | Migrations `V2` a `V8`, entidades JPA, enums, repositories e teste de persistência implementados. `mvn test` passou com Postgres local; Flyway registrou `V1` a `V8` com sucesso. |
|
||||||
|
| 03 | Motor de scraper com Playwright | `etapas/03-motor-scraper-playwright.md` | Em andamento | 2026-09-21 | Dependência Playwright instalada, Chromium baixado, serviços do scraper, DTO bruto, orquestrador e testes de parser implementados. `mvn test` passou. Pendente validação manual contra Google Maps real com 3 buscas. |
|
||||||
|
| 04 | Normalizador e padronização de dados | `etapas/04-normalizador-padronizacao.md` | Concluída | 2026-09-21 | Normalizadores de nome, telefone, CNPJ, ramo e hash implementados, com `LeadNormalizerService` e `LeadNormalizadoDTO`. `mvn test` passou. Observação: o scraper atual ainda não fornece CNPJ bruto, então o serviço mantém CNPJ `null` até esse dado existir na extração. |
|
||||||
|
| 05 | Pool de proxies e anti-bloqueio | `etapas/05-pool-proxies-anti-bloqueio.md` | Concluída | 2026-09-21 | `ProxyPoolService`, `BloqueioDetector`, `BackoffService`, delays humanizados e fingerprint variável integrados ao `BuscaOrchestrator`. Migration `V9` adiciona `proxies.em_uso`; arquitetura atualizada para v1.1. `mvn test` passou com Postgres via Docker, executado fora do sandbox para acessar `localhost:5432`. Observação: etapa 03 ainda mantém pendência de validação manual contra Google Maps real. |
|
||||||
|
| 06 | API REST do backend | `etapas/06-api-rest-backend.md` | Concluída | 2026-09-21 | JWT, endpoints de auth/buscas/leads/ramos, exportação CSV, Swagger via springdoc, worker agendado e persistência com deduplicação por `place_id`/`hash_dedup` implementados. `mvn test` passou com Postgres local, executado fora do sandbox para acessar `localhost:5432`. Observação: persistência fim-a-fim com dados reais depende da validação manual do Google Maps pendente na etapa 03. |
|
||||||
|
| 07 | Setup do dashboard frontend | `etapas/07-frontend-setup-dashboard.md` | Concluída | 2026-09-21 | Projeto React + Vite + TypeScript criado em `frontend/`, com Tailwind, componentes base estilo shadcn/ui, TanStack Query, React Hook Form + Zod, React Router e Recharts. Layout com sidebar/header, rotas protegidas por autenticação mockada e páginas mockadas implementadas. `npm run build` passou. `npm install` exigiu execução fora do sandbox para baixar pacotes. |
|
||||||
|
| 08 | Integração frontend com a API | `etapas/08-frontend-integracao-api.md` | Concluída | 2026-09-22 | Cliente HTTP com JWT, autenticação real, buscas, leads, CSV, curadoria e dashboard integrados. Backend recebeu `PATCH /leads/{id}/ramo` e `GET /leads/resumo` para fechar curadoria e agregados. `npm run build` passou; Postgres subiu via Docker Compose; `mvn test` passou fora do sandbox com acesso a `localhost:5432`. |
|
||||||
|
| 09 | Docker Compose e deploy | `etapas/09-docker-compose-deploy.md` | Concluída | 2026-09-22 | Dockerfiles do backend/frontend, Nginx com proxy `/api`, Compose com Postgres/backend/frontend, healthcheck, `.env.example` e documentação implementados. Validado com `docker compose -p leadscraper_stage09 --env-file .env.example up --build -d` em volume limpo: containers subiram, frontend respondeu 200, login direto e via `/api` responderam 200, Flyway aplicou V1 a V11 automaticamente. Observação: volume Postgres antigo mantém a senha original; para trocar senha em ambiente descartável é preciso recriar volume. |
|
||||||
|
| 10 | Testes e validação final | `etapas/10-testes-validacao-final.md` | Concluída | 2026-09-22 | Validação final executada no stack Docker reconstruído. `mvn test` passou com 16 testes; `npm run build` passou. Smoke test via Nginx `/api`: frontend 200, login 200, endpoint protegido sem token 403, com token 200, export CSV 200. Busca real `padaria` em `Campinas, SP` concluiu com 92 encontrados em aproximadamente 65s; repetição concluiu com 54 encontrados e o total persistido permaneceu 86, validando deduplicação. Curadoria validada com criação de ramo e `PATCH /leads/{id}/ramo`, reduzindo leads sem ramo de 86 para 85. Segurança revisada: `JWT_SECRET` não possui fallback hardcoded no runtime e o formulário de login não preenche senha padrão; credencial bootstrap permanece apenas como dev seed documentado. Observação: Playwright baixou navegadores adicionais no primeiro uso e registrou aviso de dependências para Firefox/WebKit, mas o fluxo Chromium/Google Maps concluiu com sucesso. |
|
||||||
|
|
||||||
|
## Regras de preenchimento
|
||||||
|
|
||||||
|
- Nunca avance para a próxima etapa com a anterior em `Bloqueada` sem registrar o motivo em "Observações"
|
||||||
|
- Se uma etapa exigir mudança no schema ou na arquitetura definida em `ARQUITETURA-SCHEMA.md`, registre isso em "Observações" e atualize a versão do documento de arquitetura correspondente
|
||||||
|
- Se surgirem etapas novas depois da 10 (funcionalidades futuras), adicione novas linhas numeradas (11, 12...) em vez de renumerar as existentes
|
||||||
|
|
||||||
|
## Log de decisões técnicas relevantes
|
||||||
|
|
||||||
|
*(preencher conforme o projeto avança — decisões que fugirem do planejado originalmente em `ARQUITETURA-SCHEMA.md` devem ser registradas aqui com data e motivo)*
|
||||||
|
|
||||||
|
- 2026-09-21: Etapa 05 adicionou `proxies.em_uso` para reserva concorrente com `FOR UPDATE SKIP LOCKED`, evitando que duas buscas usem o mesmo proxy ao mesmo tempo.
|
||||||
|
- 2026-09-22: Etapa 10 removeu fallback hardcoded de `JWT_SECRET` no runtime; ambientes locais/Docker agora precisam informar a variável explicitamente.
|
||||||
|
- 2026-09-22: Adicionada migration `V12` para atualizar o usuário bootstrap para `tiago27.dossantos@gmail.com`, mantendo compatibilidade com volumes Flyway já aplicados.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Etapa 01 — Setup do projeto, Docker e Flyway
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Criar o esqueleto do projeto backend em Spring Boot, configurar a conexão com PostgreSQL via Docker e deixar o Flyway pronto para rodar as migrations que virão nas próximas etapas.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Este é o ponto de partida do projeto. Nenhum código de negócio é escrito aqui — o foco é ambiente funcional: projeto sobe, conecta no banco, Flyway aplica a primeira migration vazia com sucesso.
|
||||||
|
|
||||||
|
Consulte `ARQUITETURA-SCHEMA.md` (seção 1 e 2) para a stack e estrutura de pacotes a seguir.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar projeto Spring Boot 3.x (via Spring Initializr ou manualmente) com as dependências:
|
||||||
|
- `spring-boot-starter-web`
|
||||||
|
- `spring-boot-starter-data-jpa`
|
||||||
|
- `spring-boot-starter-validation`
|
||||||
|
- `spring-boot-starter-security`
|
||||||
|
- `flyway-core`
|
||||||
|
- `postgresql` (driver)
|
||||||
|
- `lombok`
|
||||||
|
2. Criar a estrutura de pacotes conforme definido em `ARQUITETURA-SCHEMA.md` seção 2 (`config`, `domain`, `repository`, `scraper`, `normalizer`, `proxy`, `service`, `controller`, `security`, `worker`) — pode começar com pacotes vazios, serão populados nas próximas etapas
|
||||||
|
3. Criar `docker-compose.yml` inicial (apenas serviço `postgres`, os demais serviços virão na etapa 09):
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: leadscraper
|
||||||
|
POSTGRES_USER: leadscraper
|
||||||
|
POSTGRES_PASSWORD: <definir via .env>
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
4. Configurar `application.yml` com datasource apontando para o Postgres do Docker, e Flyway habilitado
|
||||||
|
5. Criar `src/main/resources/db/migration/V1__init.sql` vazio (ou só com um comentário) para validar que o Flyway roda corretamente na subida da aplicação
|
||||||
|
6. Criar arquivo `.env.example` com as variáveis necessárias (senha do banco, etc.) — nunca commitar `.env` real
|
||||||
|
7. Subir o Postgres via `docker compose up -d postgres` e validar que o Spring Boot conecta e aplica a migration inicial sem erro
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Projeto Spring Boot compila e sobe sem erros
|
||||||
|
- [ ] `docker compose up -d postgres` sobe o banco corretamente
|
||||||
|
- [ ] Aplicação conecta no Postgres e o Flyway registra a migration `V1__init` na tabela `flyway_schema_history`
|
||||||
|
- [ ] Estrutura de pacotes criada conforme arquitetura
|
||||||
|
- [ ] `.env.example` documentado
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Etapa 02 — Modelagem das entidades JPA
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Implementar as migrations Flyway completas do schema e as entidades JPA correspondentes, conforme definido em `ARQUITETURA-SCHEMA.md` seção 4.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Esta etapa traduz o schema SQL já definido na arquitetura para migrations Flyway numeradas e entidades Java. Não altere o schema aqui — se algo precisar mudar, volte primeiro em `ARQUITETURA-SCHEMA.md`, versione (v1.1) e só então implemente.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar as migrations Flyway, uma tabela por arquivo, na ordem de dependência (por causa das foreign keys):
|
||||||
|
- `V2__create_usuarios.sql`
|
||||||
|
- `V3__create_ramos.sql`
|
||||||
|
- `V4__create_proxies.sql`
|
||||||
|
- `V5__create_buscas.sql`
|
||||||
|
- `V6__create_leads.sql`
|
||||||
|
- `V7__create_telefones.sql`
|
||||||
|
- `V8__create_historico_contato.sql`
|
||||||
|
2. Usar exatamente o DDL definido em `ARQUITETURA-SCHEMA.md` seção 4 (colunas, tipos, constraints, índices) — não improvisar campos novos sem atualizar o documento antes
|
||||||
|
3. Criar as entidades JPA em `domain/`, uma classe por tabela: `Usuario`, `Ramo`, `Proxy`, `Busca`, `Lead`, `Telefone`, `HistoricoContato`
|
||||||
|
- Usar `@Enumerated(EnumType.STRING)` para os campos de status (mais seguro que String livre) — criar enums correspondentes: `StatusBusca`, `StatusLead`, `StatusProxy`
|
||||||
|
- Mapear os relacionamentos (`@ManyToOne`, `@OneToMany`) conforme as foreign keys do schema
|
||||||
|
4. Criar as interfaces `repository/` (Spring Data JPA) para cada entidade: `LeadRepository`, `BuscaRepository`, `RamoRepository`, `ProxyRepository`, etc.
|
||||||
|
- Adicionar métodos de consulta que já sabemos que serão necessários: `findByHashDedup`, `findByCnpj`, `findByStatus`, `findByBuscaId`
|
||||||
|
5. Rodar a aplicação e validar que todas as migrations aplicam em sequência sem erro em um banco limpo
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Todas as migrations da lista aplicam em sequência em um banco limpo
|
||||||
|
- [ ] Entidades JPA mapeiam corretamente para as tabelas (validar com um teste simples de save/find por entidade)
|
||||||
|
- [ ] Enums de status criados e usados nas entidades correspondentes
|
||||||
|
- [ ] Repositories criados com os métodos de consulta essenciais
|
||||||
|
- [ ] Nenhuma divergência entre o schema SQL aplicado e o definido em `ARQUITETURA-SCHEMA.md`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Etapa 03 — Motor de scraper com Playwright
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Implementar o módulo `scraper/` responsável por abrir o navegador, buscar no Google Maps pelo termo e localização informados, rolar o painel de resultados até esgotar os leads e extrair os dados brutos de cada card.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Nesta etapa ainda **não** há rotação de proxy nem delays humanizados sofisticados (isso é a etapa 05) — o objetivo aqui é o motor de extração funcionar de ponta a ponta em um ambiente controlado, sem se preocupar ainda com bloqueio. Também não há padronização dos dados ainda (etapa 04) — os dados saem brutos, exatamente como capturados da página.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Adicionar a dependência do Playwright for Java ao projeto e rodar o comando de instalação dos browsers (`mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"` ou equivalente)
|
||||||
|
2. Criar `scraper/GoogleMapsScraperService`, responsável por:
|
||||||
|
- Abrir uma nova instância de navegador Chromium (headless configurável via propriedade, para permitir debug visual em dev)
|
||||||
|
- Navegar até `google.com/maps`, localizar o campo de busca, digitar `termo_busca + localizacao` e submeter
|
||||||
|
- Aguardar o painel de resultados lateral carregar
|
||||||
|
3. Criar `scraper/ScrollExtractorService`, responsável por:
|
||||||
|
- Rolar o painel de resultados (não a página inteira) em incrementos, aguardando carregamento entre cada scroll
|
||||||
|
- Detectar o fim da lista (quando o número de resultados carregados para de crescer após N tentativas de scroll)
|
||||||
|
4. Criar `scraper/LeadRawExtractor`, responsável por:
|
||||||
|
- Para cada card de resultado, extrair via seletor: nome, categoria (texto bruto), endereço (texto bruto), telefone (texto bruto), site, `place_id` (se disponível na URL/atributo do elemento), avaliação
|
||||||
|
- Retornar uma lista de DTOs `LeadBrutoDTO` (sem nenhuma padronização ainda)
|
||||||
|
5. Criar `scraper/BuscaOrchestrator`, que:
|
||||||
|
- Recebe uma `Busca` (entidade), executa os passos acima em sequência, e retorna a lista de `LeadBrutoDTO` extraída
|
||||||
|
- Captura exceções de forma que uma falha em um card não interrompa a extração dos demais
|
||||||
|
6. Testar manualmente com pelo menos 3 combinações diferentes de termo/localização, incluindo um caso com poucos resultados (< 10) e um com muitos (> 50, para validar o scroll)
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Motor consegue abrir o Maps, buscar e extrair resultados de uma busca simples de ponta a ponta
|
||||||
|
- [ ] Scroll continua até esgotar os resultados reais (validado manualmente comparando com o número exibido no Maps)
|
||||||
|
- [ ] Extração captura nome, telefone bruto, endereço bruto, categoria bruta, site e `place_id` quando disponíveis
|
||||||
|
- [ ] Falha em um único card não derruba a extração dos demais
|
||||||
|
- [ ] Testado com pelo menos 3 buscas diferentes de ramos/localizações distintas
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Etapa 04 — Normalizador e padronização de dados
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Implementar o módulo `normalizer/` que transforma os `LeadBrutoDTO` extraídos na etapa 03 em dados padronizados prontos para persistência, conforme as regras definidas em `ARQUITETURA-SCHEMA.md` seção 5.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Esta etapa não persiste nada ainda — produz um `LeadNormalizadoDTO` a partir do `LeadBrutoDTO`. A persistência com deduplicação é a etapa 06 (junto com a API).
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar `normalizer/NomeNormalizer`:
|
||||||
|
- Trim + colapsar espaços múltiplos
|
||||||
|
- Title Case, preservando em maiúsculo as siglas societárias: `LTDA`, `ME`, `EIRELI`, `S/A`, `S.A.`
|
||||||
|
- Remover caracteres de controle/emoji
|
||||||
|
2. Criar `normalizer/TelefoneNormalizer`:
|
||||||
|
- Extrair apenas dígitos do texto bruto
|
||||||
|
- Assumir DDI `+55` se ausente
|
||||||
|
- Validar tamanho (DDD de 2 dígitos + número de 8 ou 9 dígitos); se inválido, retornar `null` no campo normalizado mas manter o bruto
|
||||||
|
- Formatar em E.164 (`+5543999999999`)
|
||||||
|
3. Criar `normalizer/CnpjValidator`:
|
||||||
|
- Extrair apenas dígitos (14 posições)
|
||||||
|
- Implementar validação de dígito verificador (algoritmo módulo 11)
|
||||||
|
- Se inválido, descartar o CNPJ (gravar `null`) em vez de persistir um valor incorreto
|
||||||
|
4. Criar `normalizer/RamoMapper`:
|
||||||
|
- Buscar na tabela `ramos.aliases` (JSONB) se a categoria bruta já tem mapeamento conhecido
|
||||||
|
- Se encontrar, retornar o `ramo_id` correspondente
|
||||||
|
- Se não encontrar, retornar `ramo_id = null` e manter a categoria bruta em `categoria_bruta` para curadoria manual posterior
|
||||||
|
5. Criar `normalizer/DedupHashCalculator`:
|
||||||
|
- Calcular `hash_dedup = SHA-256(nomeNormalizado + "|" + telefoneE164)`
|
||||||
|
6. Criar `normalizer/LeadNormalizerService` que orquestra os passos acima e produz o `LeadNormalizadoDTO` completo a partir de um `LeadBrutoDTO`
|
||||||
|
7. Escrever testes unitários para cada normalizador, cobrindo casos-limite:
|
||||||
|
- Telefone sem DDD, com formatação variada (parênteses, traços, espaços)
|
||||||
|
- CNPJ com dígito verificador inválido
|
||||||
|
- Nome com sigla societária em posições diferentes
|
||||||
|
- Categoria bruta sem mapeamento conhecido em `ramos`
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Todos os normalizadores implementados e cobertos por testes unitários com casos-limite
|
||||||
|
- [ ] CNPJ inválido nunca é persistido como válido
|
||||||
|
- [ ] Telefone sempre sai em E.164 quando válido, ou `null` quando não for possível normalizar
|
||||||
|
- [ ] Ramo sem mapeamento conhecido não quebra o fluxo — apenas fica pendente de curadoria
|
||||||
|
- [ ] `hash_dedup` calculado de forma determinística e reproduzível para o mesmo par nome+telefone
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Etapa 05 — Pool de proxies e anti-bloqueio
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Implementar o módulo `proxy/` e integrar rotação de proxies, delays humanizados e detecção de bloqueio ao motor de scraping da etapa 03, conforme `ARQUITETURA-SCHEMA.md` seção 6.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Esta etapa assume que a tabela `proxies` já existe (etapa 02) e que o motor de scraping básico já funciona (etapa 03). Aqui o foco é blindar o motor contra bloqueio, não mudar a lógica de extração em si.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar `proxy/ProxyPoolService`:
|
||||||
|
- Selecionar um proxy `ativo` disponível, priorizando os com `ultima_utilizacao` mais antiga (distribuição de uso mais uniforme)
|
||||||
|
- Marcar o proxy como "em uso" durante a execução de uma busca (para não ser selecionado concorrentemente por outra busca simultânea)
|
||||||
|
- Ao final da busca, atualizar `ultima_utilizacao`
|
||||||
|
2. Criar `proxy/BloqueioDetector`:
|
||||||
|
- Verificar, após cada navegação, se a página retornada é uma página de captcha/"unusual traffic" (por texto/seletor característico)
|
||||||
|
- Se detectar bloqueio: incrementar `total_bloqueios` do proxy, mudar status para `degradado` (ou `bloqueado` se ultrapassar um limite configurável de bloqueios), e sinalizar para o orchestrator tentar novamente com outro proxy
|
||||||
|
3. Criar `proxy/BackoffService`:
|
||||||
|
- Implementar backoff exponencial: se buscas consecutivas falharem por bloqueio, aumentar o intervalo de espera antes da próxima tentativa
|
||||||
|
- Expor uma configuração de limite de buscas por hora por proxy (auto-throttling), configurável via `application.yml`
|
||||||
|
4. Criar `scraper/DelayHumanizadoService`:
|
||||||
|
- Gerar intervalos aleatórios (dentro de faixas configuráveis) entre ações: digitação, scroll, troca de busca
|
||||||
|
- Aplicar scroll em passos pequenos e variáveis, nunca um salto direto
|
||||||
|
5. Integrar fingerprint variável por sessão:
|
||||||
|
- Configurar o contexto do Playwright (`BrowserContext`) com User-Agent, viewport e timezone variáveis por sessão de busca, coerentes com a região do proxy usado
|
||||||
|
6. Integrar todos os módulos acima ao `BuscaOrchestrator` da etapa 03, sem alterar a lógica de extração de dados em si
|
||||||
|
7. Atualizar `Busca` para gravar `proxy_id` usado e, em caso de erro, o `detalhe_erro`
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Proxy é selecionado do pool e liberado corretamente ao final de cada busca
|
||||||
|
- [ ] Bloqueio é detectado e o proxy correspondente é marcado como `degradado`/`bloqueado` automaticamente
|
||||||
|
- [ ] Backoff exponencial ativa após falhas consecutivas, sem exigir intervenção manual
|
||||||
|
- [ ] Delays e scroll não seguem padrão fixo (validar observando os logs/tempos entre execuções)
|
||||||
|
- [ ] Fingerprint (User-Agent, viewport, timezone) varia entre sessões de busca
|
||||||
|
- [ ] `busca.proxy_id` e `busca.detalhe_erro` são preenchidos corretamente
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Etapa 06 — API REST do backend
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Expor os endpoints REST que o frontend vai consumir: autenticação, disparo de buscas, consulta/listagem de leads, exportação e curadoria de ramos.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Esta etapa também implementa a persistência com deduplicação (que ainda não existia nas etapas anteriores) e o worker que processa buscas pendentes de forma assíncrona.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar `security/` com autenticação JWT:
|
||||||
|
- `POST /auth/login` — recebe email/senha, retorna token JWT
|
||||||
|
- Proteger todos os demais endpoints exigindo o token
|
||||||
|
2. Criar `worker/BuscaWorker`:
|
||||||
|
- Processo assíncrono (`@Scheduled` com polling, ou `@Async`) que busca registros `buscas` com status `pendente`, executa o `BuscaOrchestrator` (etapas 03+05) e, ao final, aplica o `LeadNormalizerService` (etapa 04) em cada lead extraído
|
||||||
|
- Antes de persistir, checar duplicidade por `hash_dedup` e `place_id`; se já existir, atualizar o vínculo com a nova busca em vez de duplicar
|
||||||
|
- Atualizar `busca.status`, `busca.total_encontrados`, `busca.finalizado_em` ao concluir (ou `erro` + `detalhe_erro` em caso de falha)
|
||||||
|
3. Criar `controller/BuscaController`:
|
||||||
|
- `POST /buscas` — cria uma nova busca (termo, localização, ramo opcional) com status `pendente`
|
||||||
|
- `GET /buscas` — lista buscas (paginado, filtrável por status)
|
||||||
|
- `GET /buscas/{id}` — detalhe de uma busca específica, incluindo status e total de leads encontrados
|
||||||
|
4. Criar `controller/LeadController`:
|
||||||
|
- `GET /leads` — lista leads paginada, com filtros por ramo, cidade/UF, status, busca de origem
|
||||||
|
- `GET /leads/{id}` — detalhe de um lead
|
||||||
|
- `PATCH /leads/{id}/status` — atualizar status do lead (novo, contatado, qualificado, convertido, descartado)
|
||||||
|
- `GET /leads/export` — exportação em CSV dos leads filtrados
|
||||||
|
5. Criar `controller/RamoController`:
|
||||||
|
- `GET /ramos` — lista ramos cadastrados
|
||||||
|
- `POST /ramos` — cria novo ramo (curadoria manual)
|
||||||
|
- `PATCH /ramos/{id}/aliases` — adiciona um alias a um ramo existente (para a fila de curadoria de categorias sem mapeamento)
|
||||||
|
- `GET /leads/sem-ramo` — lista leads com `ramo_id = null` pendentes de curadoria
|
||||||
|
6. Documentar a API (Swagger/OpenAPI via springdoc) para facilitar a integração do frontend
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Login retorna JWT válido e endpoints protegidos rejeitam requisição sem token
|
||||||
|
- [ ] Criar uma busca via API resulta, em segundos/minutos, em leads persistidos sem duplicação
|
||||||
|
- [ ] Listagem de leads suporta paginação e os filtros definidos
|
||||||
|
- [ ] Exportação CSV funciona com os filtros aplicados
|
||||||
|
- [ ] Fila de curadoria de ramos (`/leads/sem-ramo`) reflete corretamente os leads sem mapeamento
|
||||||
|
- [ ] Documentação Swagger acessível e atualizada
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Etapa 07 — Setup do dashboard frontend
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Criar o esqueleto do frontend (React + Vite + TypeScript) com a estrutura de páginas e componentes base, ainda sem integração real com a API (usar dados mockados).
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
Esta etapa é só estrutura visual e navegação. A integração real com o backend é a etapa 08.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar projeto com `npm create vite@latest` (template `react-ts`)
|
||||||
|
2. Instalar e configurar: Tailwind CSS, shadcn/ui, TanStack Query, React Hook Form + Zod, React Router, Recharts
|
||||||
|
3. Criar estrutura de pastas:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── LoginPage.tsx
|
||||||
|
│ ├── DashboardPage.tsx (visão geral: total de leads, buscas recentes, gráfico por ramo)
|
||||||
|
│ ├── NovaBuscaPage.tsx (formulário para disparar nova busca)
|
||||||
|
│ ├── LeadsPage.tsx (listagem/filtro/exportação de leads)
|
||||||
|
│ ├── BuscasPage.tsx (histórico e status de buscas)
|
||||||
|
│ └── CuradoriaRamosPage.tsx (fila de leads sem ramo mapeado)
|
||||||
|
├── components/
|
||||||
|
├── hooks/
|
||||||
|
├── lib/
|
||||||
|
└── types/
|
||||||
|
```
|
||||||
|
4. Implementar layout base (sidebar de navegação + header) com shadcn/ui
|
||||||
|
5. Criar as páginas acima com dados mockados (arrays estáticos) só para validar layout e navegação
|
||||||
|
6. Configurar roteamento protegido (redireciona para login se não autenticado — pode usar um mock de autenticação nesta etapa)
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Projeto sobe com `npm run dev` sem erros
|
||||||
|
- [ ] Todas as páginas listadas existem e são navegáveis pela sidebar
|
||||||
|
- [ ] Layout responsivo básico funcional (não precisa ser pixel-perfect nesta etapa)
|
||||||
|
- [ ] Dados mockados suficientes para visualizar tabelas, formulário e gráfico preenchidos
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Etapa 08 — Integração do frontend com a API
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Substituir os dados mockados da etapa 07 por integração real com a API REST do backend (etapa 06).
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar `lib/api.ts` com cliente HTTP configurado (axios ou fetch wrapper), incluindo injeção automática do token JWT nas requisições
|
||||||
|
2. Implementar autenticação real: `LoginPage` chama `POST /auth/login`, armazena token (ex: em memória + refresh via contexto React, evitar localStorage para o token se possível por segurança)
|
||||||
|
3. Integrar `NovaBuscaPage`:
|
||||||
|
- Formulário (React Hook Form + Zod) envia `POST /buscas`
|
||||||
|
- Após criar, redirecionar para `BuscasPage` mostrando a busca recém-criada com status `pendente`
|
||||||
|
4. Integrar `BuscasPage`:
|
||||||
|
- `GET /buscas` com paginação
|
||||||
|
- Polling (TanStack Query com `refetchInterval`) para atualizar status das buscas `pendente`/`em_execucao` automaticamente até virarem `concluida`/`erro`
|
||||||
|
5. Integrar `LeadsPage`:
|
||||||
|
- `GET /leads` com os filtros (ramo, cidade/UF, status, busca de origem) refletidos na URL (query params) para permitir compartilhar/voltar filtros
|
||||||
|
- Botão de exportação chamando `GET /leads/export` e disparando download do CSV
|
||||||
|
- Ação de atualizar status do lead via `PATCH /leads/{id}/status`
|
||||||
|
6. Integrar `CuradoriaRamosPage`:
|
||||||
|
- `GET /leads/sem-ramo`
|
||||||
|
- Ação para vincular a um ramo existente ou criar novo ramo com alias, chamando os endpoints de `RamoController`
|
||||||
|
7. Integrar `DashboardPage` com dados agregados reais (pode exigir um endpoint adicional simples no backend para contagens, se não existir ainda — registrar essa necessidade em `PROGRESSO.md` caso apareça)
|
||||||
|
8. Tratar estados de loading/erro em todas as páginas (skeleton states, mensagens de erro amigáveis)
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Login real funcional, com logout e expiração de sessão tratados
|
||||||
|
- [ ] Criar busca pelo frontend resulta em leads aparecendo em `LeadsPage` sem necessidade de reload manual (polling funcionando)
|
||||||
|
- [ ] Filtros de `LeadsPage` refletidos na URL e persistentes ao navegar
|
||||||
|
- [ ] Exportação CSV funcional a partir do frontend
|
||||||
|
- [ ] Curadoria de ramos funcional de ponta a ponta
|
||||||
|
- [ ] Estados de loading/erro tratados em todas as integrações
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Etapa 09 — Docker Compose e deploy
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Consolidar os três serviços (postgres, backend, frontend) em um único `docker-compose.yml` capaz de subir o ambiente completo com um comando.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. Criar `Dockerfile` do backend (multi-stage build: build com Maven, runtime com JRE 21 slim), incluindo a instalação das dependências do Playwright (Chromium) na imagem final
|
||||||
|
2. Criar `Dockerfile` do frontend (multi-stage: build com Node, servir estático via Nginx)
|
||||||
|
3. Consolidar o `docker-compose.yml` final:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: leadscraper
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: ${API_URL}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Criar `.env.example` na raiz com todas as variáveis referenciadas (`DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `API_URL`)
|
||||||
|
5. Validar que `docker compose up --build` sobe os três serviços do zero, aplica as migrations automaticamente (Flyway roda no startup do backend) e o frontend consegue falar com o backend
|
||||||
|
6. Documentar no `LEIA-ME.md` o comando de subida completa e como configurar o `.env` para produção (trocar senhas padrão, ajustar `VITE_API_URL` para o domínio real)
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] `docker compose up --build` sobe o ambiente completo em uma máquina limpa
|
||||||
|
- [ ] Migrations aplicam automaticamente sem passo manual
|
||||||
|
- [ ] Frontend acessível na porta configurada e conseguindo autenticar/consultar dados via backend
|
||||||
|
- [ ] Variáveis sensíveis (senha do banco, JWT secret) vêm de `.env`, nunca hardcoded nos Dockerfiles ou no compose
|
||||||
|
- [ ] `LEIA-ME.md` atualizado com instruções de subida
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Etapa 10 — Testes e validação final
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Validar o sistema de ponta a ponta rodando inteiramente via Docker, cobrindo o fluxo completo desde disparo de busca até exportação de leads, e revisar pontos de manutenção antes de considerar o projeto pronto para uso real.
|
||||||
|
|
||||||
|
## Tarefas
|
||||||
|
|
||||||
|
1. **Teste de fluxo completo (ambiente Docker):**
|
||||||
|
- Subir o ambiente do zero (`docker compose up --build`)
|
||||||
|
- Logar no dashboard
|
||||||
|
- Disparar uma nova busca por um ramo/localização real
|
||||||
|
- Acompanhar o status até `concluida`
|
||||||
|
- Validar na listagem de leads que os dados vieram padronizados (telefone em E.164, CNPJ válido quando presente, nome formatado)
|
||||||
|
- Exportar CSV e conferir os dados
|
||||||
|
2. **Teste de deduplicação:**
|
||||||
|
- Rodar a mesma busca (ou uma busca sobreposta, ex: mesmo ramo em raio maior) duas vezes
|
||||||
|
- Confirmar que leads já existentes não duplicam, apenas atualizam o vínculo de busca
|
||||||
|
3. **Teste de resiliência do scraper:**
|
||||||
|
- Simular/observar um cenário de bloqueio (ou revisar os logs de uma execução real que tenha batido em captcha)
|
||||||
|
- Confirmar que o proxy foi marcado como `degradado`/`bloqueado` e que o backoff funcionou
|
||||||
|
4. **Teste de curadoria de ramos:**
|
||||||
|
- Rodar uma busca com um termo cuja categoria ainda não tem alias mapeado
|
||||||
|
- Confirmar que o lead aparece em `/leads/sem-ramo` e que vincular um ramo manualmente funciona e reflete na listagem principal
|
||||||
|
5. **Revisão de segurança básica:**
|
||||||
|
- Confirmar que endpoints protegidos realmente exigem token válido
|
||||||
|
- Confirmar que senhas/segredos não estão hardcoded em nenhum arquivo versionado
|
||||||
|
6. **Revisão de performance:**
|
||||||
|
- Rodar uma busca com muitos resultados (50+) e medir tempo total do fluxo completo
|
||||||
|
- Identificar gargalos óbvios (ex: scroll muito lento, N+1 queries na listagem de leads) e registrar como itens de melhoria futura em `PROGRESSO.md`, mesmo que não sejam corrigidos nesta etapa
|
||||||
|
7. **Atualizar `PROGRESSO.md`** marcando todas as etapas como concluídas e registrando quaisquer pendências conhecidas para uma futura etapa 11+
|
||||||
|
|
||||||
|
## Critérios de aceite
|
||||||
|
|
||||||
|
- [ ] Fluxo completo validado rodando 100% via Docker, sem passos manuais fora do documentado
|
||||||
|
- [ ] Deduplicação confirmada em cenário real de busca repetida
|
||||||
|
- [ ] Comportamento de bloqueio/backoff observado e confirmado funcionando
|
||||||
|
- [ ] Curadoria de ramos validada de ponta a ponta
|
||||||
|
- [ ] Nenhum segredo hardcoded encontrado na revisão
|
||||||
|
- [ ] Gargalos de performance conhecidos documentados (mesmo que não resolvidos ainda)
|
||||||
|
- [ ] `PROGRESSO.md` refletindo o estado real final do projeto
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: leadscraper
|
||||||
|
POSTGRES_USER: ${DB_USER:-leadscraper}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d leadscraper"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${DB_USER:-leadscraper}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-480}
|
||||||
|
SERVER_PORT: 8082
|
||||||
|
BUSCA_WORKER_ENABLED: ${BUSCA_WORKER_ENABLED:-true}
|
||||||
|
BUSCA_WORKER_FIXED_DELAY_MS: ${BUSCA_WORKER_FIXED_DELAY_MS:-15000}
|
||||||
|
SCRAPER_HEADLESS: ${SCRAPER_HEADLESS:-true}
|
||||||
|
SCRAPER_TIMEOUT_MS: ${SCRAPER_TIMEOUT_MS:-300000}
|
||||||
|
SCRAPER_MAX_RESULTS: ${SCRAPER_MAX_RESULTS:-1000}
|
||||||
|
SCRAPER_SCROLL_STAGNANT_ATTEMPTS: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:-12}
|
||||||
|
SCRAPER_DELAY_MIN_MS: ${SCRAPER_DELAY_MIN_MS:-900}
|
||||||
|
SCRAPER_DELAY_MAX_MS: ${SCRAPER_DELAY_MAX_MS:-2500}
|
||||||
|
OVERPASS_ENABLED: ${OVERPASS_ENABLED:-true}
|
||||||
|
OVERPASS_ENDPOINT: ${OVERPASS_ENDPOINT:-https://overpass-api.de/api/interpreter}
|
||||||
|
OVERPASS_TIMEOUT_SECONDS: ${OVERPASS_TIMEOUT_SECONDS:-90}
|
||||||
|
OVERPASS_MAX_RESULTS: ${OVERPASS_MAX_RESULTS:-1000}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${API_URL:-/api}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: leadscraper
|
||||||
|
POSTGRES_USER: ${DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5433}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d leadscraper"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/leadscraper
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION_MINUTES: ${JWT_EXPIRATION_MINUTES:-480}
|
||||||
|
BUSCA_WORKER_ENABLED: ${BUSCA_WORKER_ENABLED:-true}
|
||||||
|
BUSCA_WORKER_FIXED_DELAY_MS: ${BUSCA_WORKER_FIXED_DELAY_MS:-15000}
|
||||||
|
SCRAPER_HEADLESS: ${SCRAPER_HEADLESS:-true}
|
||||||
|
SCRAPER_TIMEOUT_MS: ${SCRAPER_TIMEOUT_MS:-300000}
|
||||||
|
SCRAPER_MAX_RESULTS: ${SCRAPER_MAX_RESULTS:-1000}
|
||||||
|
SCRAPER_SCROLL_STAGNANT_ATTEMPTS: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:-12}
|
||||||
|
SCRAPER_DELAY_MIN_MS: ${SCRAPER_DELAY_MIN_MS:-900}
|
||||||
|
SCRAPER_DELAY_MAX_MS: ${SCRAPER_DELAY_MAX_MS:-2500}
|
||||||
|
OVERPASS_ENABLED: ${OVERPASS_ENABLED:-true}
|
||||||
|
OVERPASS_ENDPOINT: ${OVERPASS_ENDPOINT:-https://overpass-api.de/api/interpreter}
|
||||||
|
OVERPASS_TIMEOUT_SECONDS: ${OVERPASS_TIMEOUT_SECONDS:-90}
|
||||||
|
OVERPASS_MAX_RESULTS: ${OVERPASS_MAX_RESULTS:-1000}
|
||||||
|
SERVER_PORT: ${SERVER_PORT:-8082}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8082}:8082"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${API_URL:-/api}
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-8081}:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_API_BASE_URL=/api
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LeadScraper</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8082/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "leadscraper-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@tanstack/react-query": "^5.59.20",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.2",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"recharts": "^2.13.3",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.15.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.15.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
|
"globals": "^15.12.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { AppLayout } from "./components/layout/AppLayout";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
import { BuscasPage } from "./pages/BuscasPage";
|
||||||
|
import { CuradoriaRamosPage } from "./pages/CuradoriaRamosPage";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { LeadsPage } from "./pages/LeadsPage";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { NovaBuscaPage } from "./pages/NovaBuscaPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="nova-busca" element={<NovaBuscaPage />} />
|
||||||
|
<Route path="leads" element={<LeadsPage />} />
|
||||||
|
<Route path="buscas" element={<BuscasPage />} />
|
||||||
|
<Route path="curadoria-ramos" element={<CuradoriaRamosPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-background px-4 py-10 text-sm text-muted-foreground">
|
||||||
|
Carregando...
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { StatusBusca, StatusLead } from "../types/domain";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
|
const styles: Record<StatusBusca | StatusLead, string> = {
|
||||||
|
pendente: "border-slate-300 bg-slate-50 text-slate-700",
|
||||||
|
em_execucao: "border-blue-200 bg-blue-50 text-blue-700",
|
||||||
|
concluida: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
|
erro: "border-red-200 bg-red-50 text-red-700",
|
||||||
|
novo: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
||||||
|
contatado: "border-amber-200 bg-amber-50 text-amber-700",
|
||||||
|
qualificado: "border-indigo-200 bg-indigo-50 text-indigo-700",
|
||||||
|
convertido: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
|
descartado: "border-zinc-200 bg-zinc-50 text-zinc-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<StatusBusca | StatusLead, string> = {
|
||||||
|
pendente: "Pendente",
|
||||||
|
em_execucao: "Em execução",
|
||||||
|
concluida: "Concluída",
|
||||||
|
erro: "Erro",
|
||||||
|
novo: "Novo",
|
||||||
|
contatado: "Contatado",
|
||||||
|
qualificado: "Qualificado",
|
||||||
|
convertido: "Convertido",
|
||||||
|
descartado: "Descartado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: StatusBusca | StatusLead }) {
|
||||||
|
return <Badge className={styles[status]}>{labels[status]}</Badge>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
ClipboardList,
|
||||||
|
DatabaseZap,
|
||||||
|
GitBranch,
|
||||||
|
Menu,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: BarChart3 },
|
||||||
|
{ href: "/nova-busca", label: "Nova busca", icon: Search },
|
||||||
|
{ href: "/leads", label: "Leads", icon: Users },
|
||||||
|
{ href: "/buscas", label: "Buscas", icon: ClipboardList },
|
||||||
|
{ href: "/curadoria-ramos", label: "Curadoria", icon: GitBranch },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
"/": "Visão geral",
|
||||||
|
"/nova-busca": "Nova busca",
|
||||||
|
"/leads": "Leads",
|
||||||
|
"/buscas": "Histórico de buscas",
|
||||||
|
"/curadoria-ramos": "Curadoria de ramos",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const { userEmail } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 border-r bg-card lg:block">
|
||||||
|
<div className="flex h-16 items-center gap-3 border-b px-5">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||||
|
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">LeadScraper</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Prospecção SeConectai</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1 px-3 py-4">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
|
||||||
|
isActive && "bg-secondary text-foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
<header className="sticky top-0 z-20 flex h-16 items-center justify-between border-b bg-card/95 px-4 backdrop-blur md:px-6">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" className="lg:hidden" aria-label="Abrir navegação">
|
||||||
|
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-lg font-semibold md:text-xl">
|
||||||
|
{pageTitles[location.pathname] ?? "LeadScraper"}
|
||||||
|
</h1>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{userEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="px-4 py-5 md:px-6">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t bg-card lg:hidden">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex h-14 flex-col items-center justify-center gap-1 text-[11px] font-medium text-muted-foreground",
|
||||||
|
isActive && "text-primary",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<span className="max-w-full truncate px-1">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "border bg-background hover:bg-secondary",
|
||||||
|
ghost: "hover:bg-secondary",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4",
|
||||||
|
sm: "h-9 px-3",
|
||||||
|
icon: "h-10 w-10 px-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn("text-base font-semibold", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { SelectHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { HTMLAttributes, TableHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function Table({ className, ...props }: TableHTMLAttributes<HTMLTableElement>) {
|
||||||
|
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||||
|
return <thead className={cn("border-b bg-secondary/60", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||||
|
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||||
|
return <tr className={cn("border-b transition-colors hover:bg-secondary/40", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||||
|
return <th className={cn("h-10 px-3 text-left align-middle text-xs font-semibold uppercase text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||||
|
return <td className={cn("px-3 py-3 align-middle", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { api, getAuthToken, setAuthToken } from "../lib/api";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, senha: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
userEmail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(() => sessionStorage.getItem("auth-user-email"));
|
||||||
|
const [isLoading, setIsLoading] = useState(() => !getAuthToken());
|
||||||
|
|
||||||
|
async function applyLogin(email: string, senha: string) {
|
||||||
|
const response = await api.login({ email, senha });
|
||||||
|
setAuthToken(response.token);
|
||||||
|
sessionStorage.setItem("auth-user-email", email);
|
||||||
|
setToken(response.token);
|
||||||
|
setUserEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLogin("tiago27.dossantos@gmail.com", "10031992").finally(() => setIsLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
isAuthenticated: Boolean(token),
|
||||||
|
isLoading,
|
||||||
|
userEmail,
|
||||||
|
login: applyLogin,
|
||||||
|
logout: () => {
|
||||||
|
setAuthToken(null);
|
||||||
|
sessionStorage.removeItem("auth-user-email");
|
||||||
|
setToken(null);
|
||||||
|
setUserEmail(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[isLoading, token, userEmail],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth deve ser usado dentro de AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (email: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
userEmail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function MockAuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(() => localStorage.getItem("mock-user-email"));
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
isAuthenticated: Boolean(userEmail),
|
||||||
|
userEmail,
|
||||||
|
login: (email) => {
|
||||||
|
localStorage.setItem("mock-user-email", email);
|
||||||
|
setUserEmail(email);
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem("mock-user-email");
|
||||||
|
setUserEmail(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[userEmail],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMockAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useMockAuth deve ser usado dentro de MockAuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 210 33% 98%;
|
||||||
|
--foreground: 224 28% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 224 28% 12%;
|
||||||
|
--primary: 174 72% 28%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 18% 94%;
|
||||||
|
--secondary-foreground: 224 28% 16%;
|
||||||
|
--muted: 214 20% 92%;
|
||||||
|
--muted-foreground: 218 12% 42%;
|
||||||
|
--accent: 18 82% 56%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 214 20% 86%;
|
||||||
|
--input: 214 20% 86%;
|
||||||
|
--ring: 174 72% 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Busca, DashboardResumo, Lead, Page, Ramo, StatusBusca, StatusLead } from "../types/domain";
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8082";
|
||||||
|
|
||||||
|
let authToken: string | null = sessionStorage.getItem("auth-token");
|
||||||
|
|
||||||
|
export function setAuthToken(token: string | null) {
|
||||||
|
authToken = token;
|
||||||
|
if (token) {
|
||||||
|
sessionStorage.setItem("auth-token", token);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem("auth-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthToken() {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
params?: Record<string, string | number | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const url = buildApiUrl(path);
|
||||||
|
Object.entries(options.params ?? {}).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
if (options.body && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
if (response.status === 401) {
|
||||||
|
setAuthToken(null);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new ApiError(detail || "Falha na comunicação com a API", response.status);
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiUrl(path: string) {
|
||||||
|
if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
|
||||||
|
return new URL(path, API_BASE_URL);
|
||||||
|
}
|
||||||
|
return new URL(`${API_BASE_URL.replace(/\/$/, "")}${path}`, window.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (payload: { email: string; senha: string }) =>
|
||||||
|
apiRequest<{ token: string; tipo: string }>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
listarBuscas: (params: { status?: StatusBusca; page?: number; size?: number } = {}) =>
|
||||||
|
apiRequest<Page<Busca>>("/buscas", { params }),
|
||||||
|
criarBusca: (payload: {
|
||||||
|
termoBusca: string;
|
||||||
|
localizacao: string;
|
||||||
|
ramoId?: number | null;
|
||||||
|
expandirPorBairros?: boolean;
|
||||||
|
}) =>
|
||||||
|
apiRequest<Busca>("/buscas", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
listarLeads: (
|
||||||
|
params: {
|
||||||
|
ramoId?: string | number | null;
|
||||||
|
cidade?: string | null;
|
||||||
|
uf?: string | null;
|
||||||
|
status?: StatusLead | null;
|
||||||
|
buscaId?: string | number | null;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
} = {},
|
||||||
|
) => apiRequest<Page<Lead>>("/leads", { params }),
|
||||||
|
exportarLeadsUrl: (params: URLSearchParams) => {
|
||||||
|
const url = buildApiUrl("/leads/export");
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (value) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
atualizarStatusLead: (id: number, status: StatusLead) =>
|
||||||
|
apiRequest<Lead>(`/leads/${id}/status`, { method: "PATCH", body: JSON.stringify({ status }) }),
|
||||||
|
atualizarRamoLead: (id: number, ramoId: number) =>
|
||||||
|
apiRequest<Lead>(`/leads/${id}/ramo`, { method: "PATCH", body: JSON.stringify({ ramoId }) }),
|
||||||
|
listarLeadsSemRamo: () => apiRequest<Page<Lead>>("/leads/sem-ramo", { params: { size: 50 } }),
|
||||||
|
listarRamos: () => apiRequest<Ramo[]>("/ramos"),
|
||||||
|
criarRamo: (payload: { nomeCanonico: string; aliases: string[] }) =>
|
||||||
|
apiRequest<Ramo>("/ramos", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
resumoDashboard: () => apiRequest<DashboardResumo>("/leads/resumo"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import type { Busca, Lead, RamoResumo } from "../types/domain";
|
||||||
|
|
||||||
|
export const buscasMock: Busca[] = [
|
||||||
|
{
|
||||||
|
id: 1042,
|
||||||
|
termoBusca: "clínicas odontológicas",
|
||||||
|
localizacao: "Campinas, SP",
|
||||||
|
ramoId: 1,
|
||||||
|
status: "concluida",
|
||||||
|
totalEncontrados: 86,
|
||||||
|
telefonesCapturados: 72,
|
||||||
|
regioesTotal: 8,
|
||||||
|
regioesSucesso: 8,
|
||||||
|
regioesErro: 0,
|
||||||
|
detalheRegioes: "OK | Centro, Campinas, SP | 18 encontrados\nOK | Cambuí, Campinas, SP | 12 encontrados",
|
||||||
|
detalheErro: null,
|
||||||
|
iniciadoEm: null,
|
||||||
|
finalizadoEm: null,
|
||||||
|
criadoEm: "21/09/2026 16:40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1041,
|
||||||
|
termoBusca: "autopeças",
|
||||||
|
localizacao: "São José dos Campos, SP",
|
||||||
|
ramoId: 2,
|
||||||
|
status: "em_execucao",
|
||||||
|
totalEncontrados: 34,
|
||||||
|
telefonesCapturados: 21,
|
||||||
|
regioesTotal: 1,
|
||||||
|
regioesSucesso: 0,
|
||||||
|
regioesErro: 0,
|
||||||
|
detalheRegioes: null,
|
||||||
|
detalheErro: null,
|
||||||
|
iniciadoEm: null,
|
||||||
|
finalizadoEm: null,
|
||||||
|
criadoEm: "21/09/2026 16:18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1040,
|
||||||
|
termoBusca: "academias",
|
||||||
|
localizacao: "Sorocaba, SP",
|
||||||
|
ramoId: 3,
|
||||||
|
status: "pendente",
|
||||||
|
totalEncontrados: 0,
|
||||||
|
telefonesCapturados: 0,
|
||||||
|
regioesTotal: 0,
|
||||||
|
regioesSucesso: 0,
|
||||||
|
regioesErro: 0,
|
||||||
|
detalheRegioes: null,
|
||||||
|
detalheErro: null,
|
||||||
|
iniciadoEm: null,
|
||||||
|
finalizadoEm: null,
|
||||||
|
criadoEm: "21/09/2026 15:52",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1039,
|
||||||
|
termoBusca: "contabilidades",
|
||||||
|
localizacao: "Ribeirão Preto, SP",
|
||||||
|
ramoId: 4,
|
||||||
|
status: "erro",
|
||||||
|
totalEncontrados: 12,
|
||||||
|
telefonesCapturados: 8,
|
||||||
|
regioesTotal: 8,
|
||||||
|
regioesSucesso: 2,
|
||||||
|
regioesErro: 6,
|
||||||
|
detalheRegioes: "OK | Centro, Ribeirão Preto, SP | 7 encontrados\nERRO | Jardim Paulista, Ribeirão Preto, SP | Timeout 30000ms exceeded",
|
||||||
|
detalheErro: "Falha mockada",
|
||||||
|
iniciadoEm: null,
|
||||||
|
finalizadoEm: null,
|
||||||
|
criadoEm: "21/09/2026 14:20",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const leadsMock: Lead[] = [
|
||||||
|
{
|
||||||
|
id: 501,
|
||||||
|
nomeFantasia: "Odonto Prime Campinas",
|
||||||
|
telefoneE164: "+551932221100",
|
||||||
|
telefoneBruto: null,
|
||||||
|
enderecoCompleto: null,
|
||||||
|
cidade: "Campinas",
|
||||||
|
uf: "SP",
|
||||||
|
ramoId: 1,
|
||||||
|
categoriaBruta: "Clínica odontológica",
|
||||||
|
status: "novo",
|
||||||
|
buscaId: 1042,
|
||||||
|
site: "odontoprime.example",
|
||||||
|
criadoEm: "21/09/2026 16:40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 502,
|
||||||
|
nomeFantasia: "Sorriso & Cia",
|
||||||
|
telefoneE164: "+551932227788",
|
||||||
|
telefoneBruto: null,
|
||||||
|
enderecoCompleto: null,
|
||||||
|
cidade: "Campinas",
|
||||||
|
uf: "SP",
|
||||||
|
ramoId: 1,
|
||||||
|
categoriaBruta: "Dentista",
|
||||||
|
status: "contatado",
|
||||||
|
buscaId: 1042,
|
||||||
|
site: "sorrisoecia.example",
|
||||||
|
criadoEm: "21/09/2026 16:40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 503,
|
||||||
|
nomeFantasia: "Peças Vale Express",
|
||||||
|
telefoneE164: "+551239998888",
|
||||||
|
telefoneBruto: null,
|
||||||
|
enderecoCompleto: null,
|
||||||
|
cidade: "São José dos Campos",
|
||||||
|
uf: "SP",
|
||||||
|
ramoId: 2,
|
||||||
|
categoriaBruta: "Loja de autopeças",
|
||||||
|
status: "qualificado",
|
||||||
|
buscaId: 1041,
|
||||||
|
site: "pecasvale.example",
|
||||||
|
criadoEm: "21/09/2026 16:18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 504,
|
||||||
|
nomeFantasia: "Studio Movimento",
|
||||||
|
telefoneE164: "+551533334444",
|
||||||
|
telefoneBruto: null,
|
||||||
|
enderecoCompleto: null,
|
||||||
|
cidade: "Sorocaba",
|
||||||
|
uf: "SP",
|
||||||
|
ramoId: null,
|
||||||
|
categoriaBruta: "Centro de treinamento",
|
||||||
|
status: "novo",
|
||||||
|
buscaId: 1040,
|
||||||
|
site: "studiomovimento.example",
|
||||||
|
criadoEm: "21/09/2026 15:52",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 505,
|
||||||
|
nomeFantasia: "Alfa Gestão Contábil",
|
||||||
|
telefoneE164: "+551636667777",
|
||||||
|
telefoneBruto: null,
|
||||||
|
enderecoCompleto: null,
|
||||||
|
cidade: "Ribeirão Preto",
|
||||||
|
uf: "SP",
|
||||||
|
ramoId: null,
|
||||||
|
categoriaBruta: "Consultor financeiro",
|
||||||
|
status: "descartado",
|
||||||
|
buscaId: 1039,
|
||||||
|
site: "alfagestao.example",
|
||||||
|
criadoEm: "21/09/2026 14:20",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const leadsPorRamoMock: RamoResumo[] = [
|
||||||
|
{ ramo: "Saúde", total: 128 },
|
||||||
|
{ ramo: "Automotivo", total: 84 },
|
||||||
|
{ ramo: "Fitness", total: 63 },
|
||||||
|
{ ramo: "Serviços", total: 47 },
|
||||||
|
{ ramo: "Sem ramo", total: 19 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ramosMock = ["Saúde", "Automotivo", "Fitness", "Serviços", "Educação"];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { AuthProvider } from "./hooks/useAuth";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Select } from "../components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { Busca, StatusBusca } from "../types/domain";
|
||||||
|
|
||||||
|
const statusOptions: StatusBusca[] = ["pendente", "em_execucao", "concluida", "erro"];
|
||||||
|
|
||||||
|
export function BuscasPage() {
|
||||||
|
const [status, setStatus] = useState<StatusBusca | "">("");
|
||||||
|
const buscasQuery = useQuery({
|
||||||
|
queryKey: ["buscas", status],
|
||||||
|
queryFn: () => api.listarBuscas({ status: status || undefined, size: 50 }),
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.content.some((busca) => busca.status === "pendente" || busca.status === "em_execucao")
|
||||||
|
? 3000
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
const buscas = buscasQuery.data?.content ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-16 lg:pb-0">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Select className="sm:max-w-56" value={status} onChange={(event) => setStatus(event.target.value as StatusBusca | "")}>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={() => buscasQuery.refetch()} disabled={buscasQuery.isFetching}>
|
||||||
|
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Atualizar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Histórico</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Termo</TableHead>
|
||||||
|
<TableHead>Localização</TableHead>
|
||||||
|
<TableHead>Ramo</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Regiões</TableHead>
|
||||||
|
<TableHead className="text-right">Únicos salvos</TableHead>
|
||||||
|
<TableHead className="text-right">Telefones</TableHead>
|
||||||
|
<TableHead>Criada em</TableHead>
|
||||||
|
<TableHead>Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{buscas.map((busca) => (
|
||||||
|
<TableRow key={busca.id}>
|
||||||
|
<TableCell className="font-medium">#{busca.id}</TableCell>
|
||||||
|
<TableCell>{busca.termoBusca}</TableCell>
|
||||||
|
<TableCell>{busca.localizacao}</TableCell>
|
||||||
|
<TableCell>{busca.ramoId ? `#${busca.ramoId}` : "Sem ramo"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={busca.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RegionSummary busca={busca} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{busca.totalEncontrados ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
|
||||||
|
<TableCell>{new Date(busca.criadoEm).toLocaleString("pt-BR")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link to={`/leads?buscaId=${busca.id}`}>
|
||||||
|
<Users className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Ver leads
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{buscasQuery.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10}>Carregando buscas...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{buscasQuery.isError && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-destructive">
|
||||||
|
Não foi possível carregar as buscas.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegionSummary({ busca }: { busca: Busca }) {
|
||||||
|
const total = busca.regioesTotal ?? 0;
|
||||||
|
const sucesso = busca.regioesSucesso ?? 0;
|
||||||
|
const erro = busca.regioesErro ?? 0;
|
||||||
|
const processadas = Math.min(total, sucesso + erro);
|
||||||
|
const progresso = total ? Math.round((processadas / total) * 100) : 0;
|
||||||
|
const sucessoWidth = total ? (sucesso / total) * 100 : 0;
|
||||||
|
const erroWidth = total ? (erro / total) * 100 : 0;
|
||||||
|
|
||||||
|
if (!total) {
|
||||||
|
if (busca.status === "pendente" || busca.status === "em_execucao") {
|
||||||
|
return (
|
||||||
|
<div className="min-w-56 text-sm">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>Preparando bairros</span>
|
||||||
|
<span>0%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 overflow-hidden rounded-full border bg-muted">
|
||||||
|
<div className="h-full w-1/5 animate-pulse rounded-full bg-primary/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-sm text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detalhes = busca.detalheRegioes
|
||||||
|
?.split("\n")
|
||||||
|
.map((linha) => linha.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-56 text-sm">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{processadas}/{total} bairros
|
||||||
|
</span>
|
||||||
|
<span>{progresso}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 h-2.5 overflow-hidden rounded-full border bg-muted">
|
||||||
|
<div className="flex h-full w-full">
|
||||||
|
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${sucessoWidth}%` }} />
|
||||||
|
<div className="h-full bg-red-500 transition-all" style={{ width: `${erroWidth}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded border px-2 py-0.5 text-muted-foreground">{total} bairros</span>
|
||||||
|
<span className="rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-emerald-700">
|
||||||
|
{sucesso} ok
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-700">{erro} erro</span>
|
||||||
|
</div>
|
||||||
|
{!!detalhes?.length && (
|
||||||
|
<details className="mt-1 max-w-96">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted-foreground">Ver detalhes</summary>
|
||||||
|
<div className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap rounded border bg-muted/30 p-2 text-xs">
|
||||||
|
<div className="mb-1 text-muted-foreground">Por bairro: retornos brutos. Total: únicos salvos.</div>
|
||||||
|
{detalhes.join("\n")}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Select } from "../components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
export function CuradoriaRamosPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [nomeCanonico, setNomeCanonico] = useState("");
|
||||||
|
const [alias, setAlias] = useState("");
|
||||||
|
const [selecionados, setSelecionados] = useState<Record<number, string>>({});
|
||||||
|
const leadsQuery = useQuery({ queryKey: ["leads-sem-ramo"], queryFn: api.listarLeadsSemRamo });
|
||||||
|
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||||
|
const criarRamo = useMutation({
|
||||||
|
mutationFn: api.criarRamo,
|
||||||
|
onSuccess: () => {
|
||||||
|
setNomeCanonico("");
|
||||||
|
setAlias("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ramos"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mapearLead = useMutation({
|
||||||
|
mutationFn: ({ leadId, ramoId }: { leadId: number; ramoId: number }) => api.atualizarRamoLead(leadId, ramoId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leads-sem-ramo"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leads"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pendentes = leadsQuery.data?.content ?? [];
|
||||||
|
const ramos = ramosQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-16 lg:pb-0">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Novo ramo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<Input placeholder="Nome canônico" value={nomeCanonico} onChange={(event) => setNomeCanonico(event.target.value)} />
|
||||||
|
<Input placeholder="Alias inicial" value={alias} onChange={(event) => setAlias(event.target.value)} />
|
||||||
|
<Button
|
||||||
|
disabled={!nomeCanonico || criarRamo.isPending}
|
||||||
|
onClick={() => criarRamo.mutate({ nomeCanonico, aliases: alias ? [alias] : [] })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Criar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Leads sem ramo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Empresa</TableHead>
|
||||||
|
<TableHead>Categoria bruta</TableHead>
|
||||||
|
<TableHead>Cidade</TableHead>
|
||||||
|
<TableHead>Mapear para</TableHead>
|
||||||
|
<TableHead className="w-20">Ação</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pendentes.map((lead) => (
|
||||||
|
<TableRow key={lead.id}>
|
||||||
|
<TableCell className="font-medium">{lead.nomeFantasia}</TableCell>
|
||||||
|
<TableCell>{lead.categoriaBruta}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{lead.cidade}, {lead.uf}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={selecionados[lead.id] ?? ""}
|
||||||
|
onChange={(event) => setSelecionados((current) => ({ ...current, [lead.id]: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Selecionar ramo</option>
|
||||||
|
{ramos.map((ramo) => (
|
||||||
|
<option key={ramo.id} value={ramo.id}>
|
||||||
|
{ramo.nomeCanonico}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Aplicar mapeamento"
|
||||||
|
disabled={!selecionados[lead.id] || mapearLead.isPending}
|
||||||
|
onClick={() => mapearLead.mutate({ leadId: lead.id, ramoId: Number(selecionados[lead.id]) })}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{leadsQuery.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>Carregando leads pendentes...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{leadsQuery.isError && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-destructive">
|
||||||
|
Não foi possível carregar os leads sem ramo.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Activity, CheckCircle2, Phone, Users } from "lucide-react";
|
||||||
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const resumoQuery = useQuery({ queryKey: ["dashboard-resumo"], queryFn: api.resumoDashboard });
|
||||||
|
const buscasQuery = useQuery({ queryKey: ["buscas-recentes"], queryFn: () => api.listarBuscas({ size: 5 }) });
|
||||||
|
const resumo = resumoQuery.data;
|
||||||
|
const buscas = buscasQuery.data?.content ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 pb-16 lg:pb-0">
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Metric title="Leads capturados" value={resumo?.totalLeads ?? "-"} icon={Users} />
|
||||||
|
<Metric title="Telefones capturados" value={resumo?.telefonesCapturados ?? "-"} icon={Phone} />
|
||||||
|
<Metric title="Telefones únicos" value={resumo?.telefonesUnicos ?? "-"} icon={Phone} />
|
||||||
|
<Metric title="Buscas em execução" value={resumo?.buscasEmExecucao ?? "-"} icon={Activity} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Leads por ramo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={resumo?.leadsPorRamo ?? []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="ramo" tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="total" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Buscas recentes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Termo</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Leads</TableHead>
|
||||||
|
<TableHead className="text-right">Telefones</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{buscas.map((busca) => (
|
||||||
|
<TableRow key={busca.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium">{busca.termoBusca}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{busca.localizacao}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={busca.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{busca.totalEncontrados ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{busca.telefonesCapturados ?? 0}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{(resumoQuery.isError || buscasQuery.isError) && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-destructive">
|
||||||
|
Não foi possível carregar o resumo.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: typeof Users;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-secondary">
|
||||||
|
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Download, Filter } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Select } from "../components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { api, getAuthToken } from "../lib/api";
|
||||||
|
import type { StatusLead } from "../types/domain";
|
||||||
|
|
||||||
|
const leadStatuses: StatusLead[] = ["novo", "contatado", "qualificado", "convertido", "descartado"];
|
||||||
|
|
||||||
|
export function LeadsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [cidade, setCidade] = useState(searchParams.get("cidade") ?? "");
|
||||||
|
const params = useMemo(
|
||||||
|
() => ({
|
||||||
|
ramoId: searchParams.get("ramoId"),
|
||||||
|
cidade: searchParams.get("cidade"),
|
||||||
|
uf: searchParams.get("uf"),
|
||||||
|
status: searchParams.get("status") as StatusLead | null,
|
||||||
|
buscaId: searchParams.get("buscaId"),
|
||||||
|
size: 50,
|
||||||
|
}),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
const leadsQuery = useQuery({ queryKey: ["leads", params], queryFn: () => api.listarLeads(params) });
|
||||||
|
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||||
|
const atualizarStatus = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: number; status: StatusLead }) => api.atualizarStatusLead(id, status),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["leads"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateParam(key: string, value: string) {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (value) {
|
||||||
|
next.set(key, value);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
setSearchParams(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportarCsv() {
|
||||||
|
const response = await fetch(api.exportarLeadsUrl(searchParams), {
|
||||||
|
headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {},
|
||||||
|
});
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "leads.csv";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramos = ramosQuery.data ?? [];
|
||||||
|
const leads = leadsQuery.data?.content ?? [];
|
||||||
|
const buscaId = searchParams.get("buscaId");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-16 lg:pb-0">
|
||||||
|
{buscaId && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Mostrando leads da busca <span className="font-medium text-foreground">#{buscaId}</span>.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => updateParam("buscaId", "")}>
|
||||||
|
Ver todos os leads
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-3 p-4 md:grid-cols-[1fr_160px_160px_auto]">
|
||||||
|
<Input placeholder="Cidade" value={cidade} onChange={(event) => setCidade(event.target.value)} />
|
||||||
|
<Select value={searchParams.get("ramoId") ?? ""} onChange={(event) => updateParam("ramoId", event.target.value)}>
|
||||||
|
<option value="">Todos os ramos</option>
|
||||||
|
{ramos.map((ramo) => (
|
||||||
|
<option key={ramo.id} value={ramo.id}>
|
||||||
|
{ramo.nomeCanonico}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select value={searchParams.get("status") ?? ""} onChange={(event) => updateParam("status", event.target.value)}>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
{leadStatuses.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" aria-label="Aplicar filtros" onClick={() => updateParam("cidade", cidade)}>
|
||||||
|
<Filter className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={exportarCsv}>
|
||||||
|
<Download className="h-4 w-4" aria-hidden="true" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Leads capturados</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Empresa</TableHead>
|
||||||
|
<TableHead>Telefone</TableHead>
|
||||||
|
<TableHead>Cidade</TableHead>
|
||||||
|
<TableHead>Ramo</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Busca</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<TableRow key={lead.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium">{lead.nomeFantasia}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{lead.site ?? lead.enderecoCompleto}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{lead.telefoneE164 ?? lead.telefoneBruto ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{lead.cidade}, {lead.uf}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{lead.ramoId ? ramos.find((ramo) => ramo.id === lead.ramoId)?.nomeCanonico ?? `#${lead.ramoId}` : "Sem ramo"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={lead.status}
|
||||||
|
onChange={(event) => atualizarStatus.mutate({ id: lead.id, status: event.target.value as StatusLead })}
|
||||||
|
>
|
||||||
|
{leadStatuses.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>#{lead.buscaId}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{leadsQuery.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6}>Carregando leads...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{leadsQuery.isError && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-destructive">
|
||||||
|
Não foi possível carregar os leads.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { DatabaseZap } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().trim().min(1),
|
||||||
|
senha: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { isAuthenticated, login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, handleSubmit, formState, setError } = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
senha: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowValidation = formState.submitCount > 0;
|
||||||
|
|
||||||
|
async function onSubmit(values: LoginForm) {
|
||||||
|
try {
|
||||||
|
await login(values.email.trim(), values.senha);
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch {
|
||||||
|
setError("root", { message: "Email ou senha inválidos." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-background px-4 py-10">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||||
|
<DatabaseZap className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>LeadScraper</CardTitle>
|
||||||
|
<CardDescription>Acesso ao dashboard interno de prospecção.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4" noValidate onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium">Email</span>
|
||||||
|
<Input type="text" inputMode="email" autoComplete="email" {...register("email")} />
|
||||||
|
{shouldShowValidation && formState.errors.email && (
|
||||||
|
<span className="text-xs text-destructive">Email obrigatório</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium">Senha</span>
|
||||||
|
<Input type="password" autoComplete="current-password" {...register("senha")} />
|
||||||
|
{shouldShowValidation && formState.errors.senha && (
|
||||||
|
<span className="text-xs text-destructive">Senha obrigatória</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Button className="w-full" type="submit">
|
||||||
|
{formState.isSubmitting ? "Entrando..." : "Entrar"}
|
||||||
|
</Button>
|
||||||
|
{formState.errors.root && <p className="text-sm text-destructive">{formState.errors.root.message}</p>}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Play } from "lucide-react";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Select } from "../components/ui/select";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
export function NovaBuscaPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const ramosQuery = useQuery({ queryKey: ["ramos"], queryFn: api.listarRamos });
|
||||||
|
const criarBusca = useMutation({
|
||||||
|
mutationFn: api.criarBusca,
|
||||||
|
onSuccess: () => navigate("/buscas"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const termoBusca = String(formData.get("termoBusca") ?? "").trim();
|
||||||
|
const localizacao = String(formData.get("localizacao") ?? "").trim();
|
||||||
|
const ramoId = String(formData.get("ramoId") ?? "");
|
||||||
|
|
||||||
|
if (!termoBusca || !localizacao) {
|
||||||
|
setFormError("Preencha o termo de busca e a localização.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormError(null);
|
||||||
|
criarBusca.mutate({
|
||||||
|
termoBusca,
|
||||||
|
localizacao,
|
||||||
|
ramoId: ramoId ? Number(ramoId) : null,
|
||||||
|
expandirPorBairros: expanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl pb-16 lg:pb-0">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disparar busca</CardTitle>
|
||||||
|
<CardDescription>A busca entra como pendente e será processada pelo worker.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
||||||
|
<label className="block space-y-1.5 md:col-span-2">
|
||||||
|
<span className="text-sm font-medium">Termo de busca</span>
|
||||||
|
<Input
|
||||||
|
name="termoBusca"
|
||||||
|
defaultValue="restaurantes japoneses"
|
||||||
|
placeholder="Ex.: pizzaria, clínica odontológica, oficina mecânica"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium">Localização</span>
|
||||||
|
<Input name="localizacao" defaultValue="Campinas, SP" placeholder="Ex.: Campinas, SP" />
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-sm font-medium">Ramo</span>
|
||||||
|
<Select name="ramoId" defaultValue="">
|
||||||
|
<option value="">Sem ramo</option>
|
||||||
|
{(ramosQuery.data ?? []).map((ramo) => (
|
||||||
|
<option key={ramo.id} value={ramo.id}>
|
||||||
|
{ramo.nomeCanonico}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={expanded}
|
||||||
|
onChange={(event) => setExpanded(event.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="block font-medium">Expandir por bairros quando disponível</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Para cidades grandes, o sistema pesquisa regiões em segundo plano e consolida tudo nesta busca.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button type="submit" disabled={criarBusca.isPending}>
|
||||||
|
<Play className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{criarBusca.isPending ? "Criando..." : "Criar busca"}
|
||||||
|
</Button>
|
||||||
|
{formError && <p className="mt-2 text-sm text-destructive">{formError}</p>}
|
||||||
|
{criarBusca.isError && (
|
||||||
|
<p className="mt-2 text-sm text-destructive">
|
||||||
|
Não foi possível criar a busca. Atualize a página e tente novamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type StatusBusca = "pendente" | "em_execucao" | "concluida" | "erro";
|
||||||
|
|
||||||
|
export type StatusLead = "novo" | "contatado" | "qualificado" | "convertido" | "descartado";
|
||||||
|
|
||||||
|
export interface Busca {
|
||||||
|
id: number;
|
||||||
|
termoBusca: string;
|
||||||
|
localizacao: string;
|
||||||
|
ramoId: number | null;
|
||||||
|
status: StatusBusca;
|
||||||
|
totalEncontrados: number | null;
|
||||||
|
telefonesCapturados: number;
|
||||||
|
regioesTotal: number | null;
|
||||||
|
regioesSucesso: number | null;
|
||||||
|
regioesErro: number | null;
|
||||||
|
detalheRegioes: string | null;
|
||||||
|
detalheErro: string | null;
|
||||||
|
iniciadoEm: string | null;
|
||||||
|
finalizadoEm: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: number;
|
||||||
|
nomeFantasia: string;
|
||||||
|
telefoneE164: string;
|
||||||
|
telefoneBruto: string | null;
|
||||||
|
enderecoCompleto: string | null;
|
||||||
|
cidade: string;
|
||||||
|
uf: string;
|
||||||
|
ramoId: number | null;
|
||||||
|
categoriaBruta: string;
|
||||||
|
status: StatusLead;
|
||||||
|
buscaId: number;
|
||||||
|
site: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RamoResumo {
|
||||||
|
ramo: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ramo {
|
||||||
|
id: number;
|
||||||
|
nomeCanonico: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page<T> {
|
||||||
|
content: T[];
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResumo {
|
||||||
|
totalLeads: number;
|
||||||
|
telefonesCapturados: number;
|
||||||
|
telefonesUnicos: number;
|
||||||
|
buscasEmExecucao: number;
|
||||||
|
aguardandoCuradoria: number;
|
||||||
|
convertidos: number;
|
||||||
|
leadsPorRamo: RamoResumo[];
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "0.5rem",
|
||||||
|
md: "0.375rem",
|
||||||
|
sm: "0.25rem",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.seconectai</groupId>
|
||||||
|
<artifactId>leadscraper</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>LeadScraper</name>
|
||||||
|
<description>Motor de busca e prospeccao de leads via automacao de navegador.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
|
<artifactId>playwright</artifactId>
|
||||||
|
<version>1.48.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.6.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RAW_BASE="${RAW_BASE:-https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai/raw/branch/main/scripts}"
|
||||||
|
BIN_DIR="${BIN_DIR:-/usr/local/bin}"
|
||||||
|
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "Erro: curl nao encontrado. Instale com: apt update && apt install -y curl"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
curl -fsSL "$RAW_BASE/$name" -o "$BIN_DIR/$name"
|
||||||
|
chmod 0755 "$BIN_DIR/$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_cmd seconectaiescavador.install
|
||||||
|
install_cmd seconectaiescavador.update
|
||||||
|
|
||||||
|
echo "Comandos instalados em $BIN_DIR:"
|
||||||
|
echo " seconectaiescavador.install"
|
||||||
|
echo " seconectaiescavador.update"
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
install -m 0755 scripts/seconectaiescavador.install /usr/local/bin/seconectaiescavador.install
|
||||||
|
install -m 0755 scripts/seconectaiescavador.update /usr/local/bin/seconectaiescavador.update
|
||||||
|
|
||||||
|
echo "Comandos instalados:"
|
||||||
|
echo " seconectaiescavador.install"
|
||||||
|
echo " seconectaiescavador.update"
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${DOMAIN:-escavador.seconectai.com.br}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||||
|
echo "Erro: $COMPOSE_FILE nao encontrado. Rode este script na raiz do projeto."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Erro: Docker nao encontrado. Instale Docker e Docker Compose plugin antes de continuar."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
secret() {
|
||||||
|
openssl rand -base64 32 | tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_PASSWORD="$(secret)"
|
||||||
|
JWT_SECRET="$(secret)"
|
||||||
|
|
||||||
|
cat > "$ENV_FILE" <<EOF_ENV
|
||||||
|
DB_USER=leadscraper
|
||||||
|
DB_PASSWORD=$DB_PASSWORD
|
||||||
|
DB_PORT=5433
|
||||||
|
BACKEND_PORT=8082
|
||||||
|
FRONTEND_PORT=8081
|
||||||
|
SERVER_PORT=8082
|
||||||
|
|
||||||
|
JWT_SECRET=$JWT_SECRET
|
||||||
|
JWT_EXPIRATION_MINUTES=480
|
||||||
|
API_URL=/api
|
||||||
|
APP_PUBLIC_URL=https://$DOMAIN
|
||||||
|
|
||||||
|
BUSCA_WORKER_ENABLED=true
|
||||||
|
BUSCA_WORKER_FIXED_DELAY_MS=15000
|
||||||
|
SCRAPER_HEADLESS=true
|
||||||
|
SCRAPER_TIMEOUT_MS=300000
|
||||||
|
SCRAPER_MAX_RESULTS=1000
|
||||||
|
SCRAPER_SCROLL_STAGNANT_ATTEMPTS=12
|
||||||
|
SCRAPER_DELAY_MIN_MS=900
|
||||||
|
SCRAPER_DELAY_MAX_MS=2500
|
||||||
|
OVERPASS_ENABLED=true
|
||||||
|
OVERPASS_ENDPOINT=https://overpass-api.de/api/interpreter
|
||||||
|
OVERPASS_TIMEOUT_SECONDS=90
|
||||||
|
OVERPASS_MAX_RESULTS=1000
|
||||||
|
EOF_ENV
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Instalacao concluida."
|
||||||
|
echo "URL: https://$DOMAIN"
|
||||||
|
echo "Frontend interno: http://localhost:8081"
|
||||||
|
echo "Backend interno: http://localhost:8082"
|
||||||
|
echo
|
||||||
|
echo "Segredos gerados em $ENV_FILE."
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="${REPO_URL:-https://producao-gitea.0jo7zj.easypanel.host/tiago/Prospeccao_SeConectai.git}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/Prospeccao_SeConectai}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
DOMAIN="${DOMAIN:-escavador.seconectai.com.br}"
|
||||||
|
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo "Erro: git nao encontrado. Instale com: apt update && apt install -y git"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Erro: Docker nao encontrado. Instale Docker antes de continuar."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
echo "Repositorio ja existe em $INSTALL_DIR. Sincronizando com o Gitea..."
|
||||||
|
git -C "$INSTALL_DIR" remote remove origin >/dev/null 2>&1 || true
|
||||||
|
git -C "$INSTALL_DIR" remote add origin "$REPO_URL"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout -B "$BRANCH" "origin/$BRANCH" -f
|
||||||
|
else
|
||||||
|
if [[ -e "$INSTALL_DIR" && -n "$(ls -A "$INSTALL_DIR" 2>/dev/null || true)" ]]; then
|
||||||
|
if [[ -f "$INSTALL_DIR/pom.xml" && -d "$INSTALL_DIR/frontend" ]]; then
|
||||||
|
echo "Projeto encontrado em $INSTALL_DIR sem .git. Conectando ao Gitea e atualizando..."
|
||||||
|
git -C "$INSTALL_DIR" init
|
||||||
|
git -C "$INSTALL_DIR" remote remove origin >/dev/null 2>&1 || true
|
||||||
|
git -C "$INSTALL_DIR" remote add origin "$REPO_URL"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout -B "$BRANCH" "origin/$BRANCH" -f
|
||||||
|
else
|
||||||
|
echo "Erro: $INSTALL_DIR ja existe e nao parece ser este projeto."
|
||||||
|
echo "Use outro INSTALL_DIR ou mova a pasta atual antes de instalar."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
DOMAIN="$DOMAIN" ./scripts/install-prod.sh
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/Prospeccao_SeConectai}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
|
||||||
|
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
echo "Erro: repositorio nao encontrado em $INSTALL_DIR. Rode primeiro: seconectaiescavador.install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
BRANCH="$BRANCH" ./scripts/update-prod.sh
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||||
|
echo "Erro: $COMPOSE_FILE nao encontrado. Rode este script na raiz do projeto."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
echo "Erro: .env nao encontrado. Rode scripts/install-prod.sh primeiro ou crie o .env de producao."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d .git ]]; then
|
||||||
|
git fetch origin "$BRANCH"
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
git pull --ff-only origin "$BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.seconectai.leadscraper;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@EnableScheduling
|
||||||
|
@SpringBootApplication
|
||||||
|
public class LeadScraperApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(LeadScraperApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.seconectai.leadscraper.config;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.scraper.GoogleMapsScraperProperties;
|
||||||
|
import com.seconectai.leadscraper.scraper.OverpassScraperProperties;
|
||||||
|
import com.seconectai.leadscraper.proxy.ProxySettingsProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties({
|
||||||
|
GoogleMapsScraperProperties.class,
|
||||||
|
OverpassScraperProperties.class,
|
||||||
|
ProxySettingsProperties.class
|
||||||
|
})
|
||||||
|
public class ScraperConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.seconectai.leadscraper.config;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.security.JwtAuthenticationFilter;
|
||||||
|
import com.seconectai.leadscraper.security.JwtProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(JwtProperties.class)
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter)
|
||||||
|
throws Exception {
|
||||||
|
return http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
|
||||||
|
throws Exception {
|
||||||
|
return authenticationConfiguration.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.seconectai.leadscraper.controller;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.repository.UsuarioRepository;
|
||||||
|
import com.seconectai.leadscraper.security.JwtService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
private final UsuarioRepository usuarioRepository;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
public AuthController(
|
||||||
|
AuthenticationManager authenticationManager,
|
||||||
|
UsuarioRepository usuarioRepository,
|
||||||
|
JwtService jwtService
|
||||||
|
) {
|
||||||
|
this.authenticationManager = authenticationManager;
|
||||||
|
this.usuarioRepository = usuarioRepository;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(request.email(), request.senha())
|
||||||
|
);
|
||||||
|
var usuario = usuarioRepository.findByEmail(request.email()).orElseThrow();
|
||||||
|
return new LoginResponse(jwtService.generateToken(usuario.getEmail(), usuario.getPapel()), "Bearer");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(@Email @NotBlank String email, @NotBlank String senha) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginResponse(String token, String tipo) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.seconectai.leadscraper.controller;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Busca;
|
||||||
|
import com.seconectai.leadscraper.domain.Ramo;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||||
|
import com.seconectai.leadscraper.repository.BuscaRepository;
|
||||||
|
import com.seconectai.leadscraper.repository.LeadRepository;
|
||||||
|
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/buscas")
|
||||||
|
public class BuscaController {
|
||||||
|
|
||||||
|
private final BuscaRepository buscaRepository;
|
||||||
|
private final LeadRepository leadRepository;
|
||||||
|
private final RamoRepository ramoRepository;
|
||||||
|
|
||||||
|
public BuscaController(BuscaRepository buscaRepository, LeadRepository leadRepository, RamoRepository ramoRepository) {
|
||||||
|
this.buscaRepository = buscaRepository;
|
||||||
|
this.leadRepository = leadRepository;
|
||||||
|
this.ramoRepository = ramoRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public BuscaResponse criar(@Valid @RequestBody CriarBuscaRequest request) {
|
||||||
|
Busca busca = new Busca();
|
||||||
|
busca.setTermoBusca(request.termoBusca());
|
||||||
|
busca.setLocalizacao(request.localizacao());
|
||||||
|
busca.setRamo(resolveRamo(request.ramoId()));
|
||||||
|
busca.setExpandirPorBairros(request.expandirPorBairros() == null || request.expandirPorBairros());
|
||||||
|
busca.setStatus(StatusBusca.pendente);
|
||||||
|
return toResponse(buscaRepository.save(busca));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Page<BuscaResponse> listar(@RequestParam(required = false) StatusBusca status, Pageable pageable) {
|
||||||
|
Specification<Busca> spec = (root, query, cb) -> status == null
|
||||||
|
? cb.conjunction()
|
||||||
|
: cb.equal(root.get("status"), status);
|
||||||
|
return buscaRepository.findAll(spec, pageable).map(this::toResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public BuscaResponse detalhar(@PathVariable Long id) {
|
||||||
|
return toResponse(buscaRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Busca nao encontrada")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuscaResponse toResponse(Busca busca) {
|
||||||
|
long telefonesCapturados = busca.getId() == null ? 0 : leadRepository.countByBuscaIdAndTelefoneE164IsNotNull(busca.getId());
|
||||||
|
return BuscaResponse.from(busca, telefonesCapturados);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Ramo resolveRamo(Long ramoId) {
|
||||||
|
if (ramoId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ramoRepository.findById(ramoId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CriarBuscaRequest(
|
||||||
|
@NotBlank String termoBusca,
|
||||||
|
@NotBlank String localizacao,
|
||||||
|
Long ramoId,
|
||||||
|
Boolean expandirPorBairros
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BuscaResponse(
|
||||||
|
Long id,
|
||||||
|
String termoBusca,
|
||||||
|
String localizacao,
|
||||||
|
Long ramoId,
|
||||||
|
StatusBusca status,
|
||||||
|
Integer totalEncontrados,
|
||||||
|
long telefonesCapturados,
|
||||||
|
Integer regioesTotal,
|
||||||
|
Integer regioesSucesso,
|
||||||
|
Integer regioesErro,
|
||||||
|
String detalheRegioes,
|
||||||
|
String detalheErro,
|
||||||
|
OffsetDateTime iniciadoEm,
|
||||||
|
OffsetDateTime finalizadoEm,
|
||||||
|
OffsetDateTime criadoEm
|
||||||
|
) {
|
||||||
|
static BuscaResponse from(Busca busca, long telefonesCapturados) {
|
||||||
|
return new BuscaResponse(
|
||||||
|
busca.getId(),
|
||||||
|
busca.getTermoBusca(),
|
||||||
|
busca.getLocalizacao(),
|
||||||
|
busca.getRamo() == null ? null : busca.getRamo().getId(),
|
||||||
|
busca.getStatus(),
|
||||||
|
busca.getTotalEncontrados(),
|
||||||
|
telefonesCapturados,
|
||||||
|
busca.getRegioesTotal(),
|
||||||
|
busca.getRegioesSucesso(),
|
||||||
|
busca.getRegioesErro(),
|
||||||
|
busca.getDetalheRegioes(),
|
||||||
|
busca.getDetalheErro(),
|
||||||
|
busca.getIniciadoEm(),
|
||||||
|
busca.getFinalizadoEm(),
|
||||||
|
busca.getCriadoEm()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package com.seconectai.leadscraper.controller;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Lead;
|
||||||
|
import com.seconectai.leadscraper.domain.Ramo;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusLead;
|
||||||
|
import com.seconectai.leadscraper.repository.BuscaRepository;
|
||||||
|
import com.seconectai.leadscraper.repository.LeadRepository;
|
||||||
|
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/leads")
|
||||||
|
public class LeadController {
|
||||||
|
|
||||||
|
private final LeadRepository leadRepository;
|
||||||
|
private final RamoRepository ramoRepository;
|
||||||
|
private final BuscaRepository buscaRepository;
|
||||||
|
|
||||||
|
public LeadController(LeadRepository leadRepository, RamoRepository ramoRepository, BuscaRepository buscaRepository) {
|
||||||
|
this.leadRepository = leadRepository;
|
||||||
|
this.ramoRepository = ramoRepository;
|
||||||
|
this.buscaRepository = buscaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Page<LeadResponse> listar(LeadFilter filter, Pageable pageable) {
|
||||||
|
return leadRepository.findAll(toSpec(filter), pageable).map(LeadResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public LeadResponse detalhar(@PathVariable Long id) {
|
||||||
|
return LeadResponse.from(findLead(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
@Transactional
|
||||||
|
public LeadResponse atualizarStatus(@PathVariable Long id, @Valid @RequestBody AtualizarStatusRequest request) {
|
||||||
|
Lead lead = findLead(id);
|
||||||
|
lead.setStatus(request.status());
|
||||||
|
return LeadResponse.from(leadRepository.save(lead));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/ramo")
|
||||||
|
@Transactional
|
||||||
|
public LeadResponse atualizarRamo(@PathVariable Long id, @Valid @RequestBody AtualizarRamoRequest request) {
|
||||||
|
Lead lead = findLead(id);
|
||||||
|
Ramo ramo = ramoRepository.findById(request.ramoId())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
|
||||||
|
lead.setRamo(ramo);
|
||||||
|
return LeadResponse.from(leadRepository.save(lead));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/resumo")
|
||||||
|
public DashboardResumoResponse resumo() {
|
||||||
|
List<Lead> leads = leadRepository.findAll();
|
||||||
|
Map<String, Long> porRamo = leads.stream()
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
lead -> lead.getRamo() == null ? "Sem ramo" : lead.getRamo().getNomeCanonico(),
|
||||||
|
Collectors.counting()
|
||||||
|
));
|
||||||
|
List<RamoResumoResponse> leadsPorRamo = porRamo.entrySet().stream()
|
||||||
|
.map(entry -> new RamoResumoResponse(entry.getKey(), entry.getValue()))
|
||||||
|
.sorted((left, right) -> Long.compare(right.total(), left.total()))
|
||||||
|
.toList();
|
||||||
|
long buscasEmExecucao = buscaRepository.findByStatus(StatusBusca.em_execucao).size();
|
||||||
|
long telefonesCapturados = leadRepository.countByTelefoneE164IsNotNull();
|
||||||
|
long telefonesUnicos = leadRepository.countDistinctTelefonesCapturados();
|
||||||
|
long aguardandoCuradoria = leads.stream().filter(lead -> lead.getRamo() == null).count();
|
||||||
|
long convertidos = leads.stream().filter(lead -> lead.getStatus() == StatusLead.convertido).count();
|
||||||
|
return new DashboardResumoResponse(leads.size(), telefonesCapturados, telefonesUnicos, buscasEmExecucao, aguardandoCuradoria, convertidos, leadsPorRamo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
public void exportar(LeadFilter filter, HttpServletResponse response) throws IOException {
|
||||||
|
response.setContentType("text/csv;charset=UTF-8");
|
||||||
|
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=leads.csv");
|
||||||
|
try (PrintWriter writer = response.getWriter()) {
|
||||||
|
writer.println("id,nome_fantasia,telefone_e164,telefone_bruto,site,cidade,uf,ramo_id,status,busca_id");
|
||||||
|
leadRepository.findAll(toSpec(filter)).forEach(lead -> writer.println(csv(lead)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sem-ramo")
|
||||||
|
public Page<LeadResponse> semRamo(Pageable pageable) {
|
||||||
|
Specification<Lead> spec = (root, query, cb) -> cb.isNull(root.get("ramo"));
|
||||||
|
return leadRepository.findAll(spec, pageable).map(LeadResponse::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Lead findLead(Long id) {
|
||||||
|
return leadRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nao encontrado"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<Lead> toSpec(LeadFilter filter) {
|
||||||
|
return Specification.allOf(
|
||||||
|
equalsIfPresent("ramo.id", filter.ramoId()),
|
||||||
|
likeIfPresent("cidade", filter.cidade()),
|
||||||
|
likeIfPresent("uf", filter.uf()),
|
||||||
|
equalsIfPresent("status", filter.status()),
|
||||||
|
equalsIfPresent("busca.id", filter.buscaId())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<Lead> equalsIfPresent(String path, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (root, query, cb) -> cb.equal(resolvePath(root, path), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Specification<Lead> likeIfPresent(String field, String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (root, query, cb) -> cb.like(cb.lower(root.get(field)), "%" + value.toLowerCase() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
private jakarta.persistence.criteria.Path<?> resolvePath(jakarta.persistence.criteria.Root<Lead> root, String path) {
|
||||||
|
jakarta.persistence.criteria.Path<?> current = root;
|
||||||
|
for (String part : path.split("\\.")) {
|
||||||
|
current = current.get(part);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String csv(Lead lead) {
|
||||||
|
return String.join(",",
|
||||||
|
escape(lead.getId()),
|
||||||
|
escape(lead.getNomeFantasia()),
|
||||||
|
escape(lead.getTelefoneE164()),
|
||||||
|
escape(lead.getTelefoneBruto()),
|
||||||
|
escape(lead.getSite()),
|
||||||
|
escape(lead.getCidade()),
|
||||||
|
escape(lead.getUf()),
|
||||||
|
escape(lead.getRamo() == null ? null : lead.getRamo().getId()),
|
||||||
|
escape(lead.getStatus()),
|
||||||
|
escape(lead.getBusca() == null ? null : lead.getBusca().getId())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escape(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = value.toString().replace("\"", "\"\"");
|
||||||
|
return "\"" + text + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LeadFilter(Long ramoId, String cidade, String uf, StatusLead status, Long buscaId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AtualizarStatusRequest(@NotNull StatusLead status) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AtualizarRamoRequest(@NotNull Long ramoId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RamoResumoResponse(String ramo, Long total) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DashboardResumoResponse(
|
||||||
|
long totalLeads,
|
||||||
|
long telefonesCapturados,
|
||||||
|
long telefonesUnicos,
|
||||||
|
long buscasEmExecucao,
|
||||||
|
long aguardandoCuradoria,
|
||||||
|
long convertidos,
|
||||||
|
List<RamoResumoResponse> leadsPorRamo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LeadResponse(
|
||||||
|
Long id,
|
||||||
|
String nomeFantasia,
|
||||||
|
String telefoneE164,
|
||||||
|
String telefoneBruto,
|
||||||
|
String site,
|
||||||
|
String enderecoCompleto,
|
||||||
|
String cidade,
|
||||||
|
String uf,
|
||||||
|
Long ramoId,
|
||||||
|
String categoriaBruta,
|
||||||
|
StatusLead status,
|
||||||
|
Long buscaId,
|
||||||
|
OffsetDateTime criadoEm
|
||||||
|
) {
|
||||||
|
static LeadResponse from(Lead lead) {
|
||||||
|
return new LeadResponse(
|
||||||
|
lead.getId(),
|
||||||
|
lead.getNomeFantasia(),
|
||||||
|
lead.getTelefoneE164(),
|
||||||
|
lead.getTelefoneBruto(),
|
||||||
|
lead.getSite(),
|
||||||
|
lead.getEnderecoCompleto(),
|
||||||
|
lead.getCidade(),
|
||||||
|
lead.getUf(),
|
||||||
|
lead.getRamo() == null ? null : lead.getRamo().getId(),
|
||||||
|
lead.getCategoriaBruta(),
|
||||||
|
lead.getStatus(),
|
||||||
|
lead.getBusca() == null ? null : lead.getBusca().getId(),
|
||||||
|
lead.getCriadoEm()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.seconectai.leadscraper.controller;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Ramo;
|
||||||
|
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/ramos")
|
||||||
|
public class RamoController {
|
||||||
|
|
||||||
|
private final RamoRepository ramoRepository;
|
||||||
|
|
||||||
|
public RamoController(RamoRepository ramoRepository) {
|
||||||
|
this.ramoRepository = ramoRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<RamoResponse> listar() {
|
||||||
|
return ramoRepository.findAll().stream().map(RamoResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public RamoResponse criar(@Valid @RequestBody CriarRamoRequest request) {
|
||||||
|
Ramo ramo = new Ramo();
|
||||||
|
ramo.setNomeCanonico(request.nomeCanonico());
|
||||||
|
ramo.setAliases(request.aliases() == null ? new ArrayList<>() : new ArrayList<>(request.aliases()));
|
||||||
|
return RamoResponse.from(ramoRepository.save(ramo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/aliases")
|
||||||
|
public RamoResponse adicionarAlias(@PathVariable Long id, @Valid @RequestBody AdicionarAliasRequest request) {
|
||||||
|
Ramo ramo = ramoRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ramo nao encontrado"));
|
||||||
|
List<String> aliases = new ArrayList<>(ramo.getAliases());
|
||||||
|
if (!aliases.contains(request.alias())) {
|
||||||
|
aliases.add(request.alias());
|
||||||
|
}
|
||||||
|
ramo.setAliases(aliases);
|
||||||
|
return RamoResponse.from(ramoRepository.save(ramo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CriarRamoRequest(@NotBlank String nomeCanonico, List<String> aliases) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AdicionarAliasRequest(@NotBlank String alias) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RamoResponse(Long id, String nomeCanonico, List<String> aliases) {
|
||||||
|
static RamoResponse from(Ramo ramo) {
|
||||||
|
return new RamoResponse(ramo.getId(), ramo.getNomeCanonico(), ramo.getAliases());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "buscas")
|
||||||
|
public class Busca {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "termo_busca", nullable = false, length = 150)
|
||||||
|
private String termoBusca;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 150)
|
||||||
|
private String localizacao;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "ramo_id")
|
||||||
|
private Ramo ramo;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "proxy_id")
|
||||||
|
private Proxy proxy;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private StatusBusca status = StatusBusca.pendente;
|
||||||
|
|
||||||
|
@Column(name = "total_encontrados")
|
||||||
|
private Integer totalEncontrados = 0;
|
||||||
|
|
||||||
|
@Column(name = "detalhe_erro", columnDefinition = "text")
|
||||||
|
private String detalheErro;
|
||||||
|
|
||||||
|
@Column(name = "expandir_por_bairros")
|
||||||
|
private Boolean expandirPorBairros = true;
|
||||||
|
|
||||||
|
@Column(name = "regioes_total")
|
||||||
|
private Integer regioesTotal = 0;
|
||||||
|
|
||||||
|
@Column(name = "regioes_sucesso")
|
||||||
|
private Integer regioesSucesso = 0;
|
||||||
|
|
||||||
|
@Column(name = "regioes_erro")
|
||||||
|
private Integer regioesErro = 0;
|
||||||
|
|
||||||
|
@Column(name = "detalhe_regioes", columnDefinition = "text")
|
||||||
|
private String detalheRegioes;
|
||||||
|
|
||||||
|
@Column(name = "iniciado_em")
|
||||||
|
private OffsetDateTime iniciadoEm;
|
||||||
|
|
||||||
|
@Column(name = "finalizado_em")
|
||||||
|
private OffsetDateTime finalizadoEm;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "criado_por")
|
||||||
|
private Usuario criadoPor;
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "busca")
|
||||||
|
private List<Lead> leads = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "historico_contato")
|
||||||
|
public class HistoricoContato {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "lead_id", nullable = false)
|
||||||
|
private Lead lead;
|
||||||
|
|
||||||
|
@Column(length = 30)
|
||||||
|
private String canal;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String resultado;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text")
|
||||||
|
private String observacao;
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "leads")
|
||||||
|
public class Lead {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "place_id", length = 150)
|
||||||
|
private String placeId;
|
||||||
|
|
||||||
|
@Column(name = "razao_social", length = 200)
|
||||||
|
private String razaoSocial;
|
||||||
|
|
||||||
|
@Column(name = "nome_fantasia", nullable = false, length = 200)
|
||||||
|
private String nomeFantasia;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "char(14)")
|
||||||
|
@JdbcTypeCode(SqlTypes.CHAR)
|
||||||
|
private String cnpj;
|
||||||
|
|
||||||
|
@Column(name = "telefone_e164", length = 20)
|
||||||
|
private String telefoneE164;
|
||||||
|
|
||||||
|
@Column(name = "telefone_bruto", length = 40)
|
||||||
|
private String telefoneBruto;
|
||||||
|
|
||||||
|
@Column(length = 150)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
private String site;
|
||||||
|
|
||||||
|
@Column(name = "endereco_completo", columnDefinition = "text")
|
||||||
|
private String enderecoCompleto;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String logradouro;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String numero;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String bairro;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String cidade;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "char(2)")
|
||||||
|
@JdbcTypeCode(SqlTypes.CHAR)
|
||||||
|
private String uf;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "char(8)")
|
||||||
|
@JdbcTypeCode(SqlTypes.CHAR)
|
||||||
|
private String cep;
|
||||||
|
|
||||||
|
@Column(precision = 10, scale = 7)
|
||||||
|
private BigDecimal latitude;
|
||||||
|
|
||||||
|
@Column(precision = 10, scale = 7)
|
||||||
|
private BigDecimal longitude;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "ramo_id")
|
||||||
|
private Ramo ramo;
|
||||||
|
|
||||||
|
@Column(name = "categoria_bruta", length = 150)
|
||||||
|
private String categoriaBruta;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 30)
|
||||||
|
private String fonte = "google_maps";
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "busca_id")
|
||||||
|
private Busca busca;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private StatusLead status = StatusLead.novo;
|
||||||
|
|
||||||
|
@Column(name = "hash_dedup", nullable = false, unique = true, length = 64)
|
||||||
|
private String hashDedup;
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
|
||||||
|
@Column(name = "atualizado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime atualizadoEm;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "lead")
|
||||||
|
private List<Telefone> telefones = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "lead")
|
||||||
|
private List<HistoricoContato> historicoContatos = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "proxies")
|
||||||
|
public class Proxy {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String endereco;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String tipo = "residencial";
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(length = 20)
|
||||||
|
private StatusProxy status = StatusProxy.ativo;
|
||||||
|
|
||||||
|
@Column(name = "total_bloqueios")
|
||||||
|
private Integer totalBloqueios = 0;
|
||||||
|
|
||||||
|
@Column(name = "em_uso")
|
||||||
|
private Boolean emUso = false;
|
||||||
|
|
||||||
|
@Column(name = "ultima_utilizacao")
|
||||||
|
private OffsetDateTime ultimaUtilizacao;
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "proxy")
|
||||||
|
private List<Busca> buscas = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "ramos")
|
||||||
|
public class Ramo {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "nome_canonico", nullable = false, unique = true, length = 100)
|
||||||
|
private String nomeCanonico;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "categoria_pai_id")
|
||||||
|
private Ramo categoriaPai;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(nullable = false, columnDefinition = "jsonb")
|
||||||
|
private List<String> aliases = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "categoriaPai")
|
||||||
|
private List<Ramo> subcategorias = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "ramo")
|
||||||
|
private List<Busca> buscas = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "ramo")
|
||||||
|
private List<Lead> leads = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
public enum StatusBusca {
|
||||||
|
pendente,
|
||||||
|
em_execucao,
|
||||||
|
concluida,
|
||||||
|
erro
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
public enum StatusLead {
|
||||||
|
novo,
|
||||||
|
contatado,
|
||||||
|
qualificado,
|
||||||
|
convertido,
|
||||||
|
descartado
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
public enum StatusProxy {
|
||||||
|
ativo,
|
||||||
|
degradado,
|
||||||
|
bloqueado
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.util.Objects;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "telefones")
|
||||||
|
public class Telefone {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "lead_id", nullable = false)
|
||||||
|
private Lead lead;
|
||||||
|
|
||||||
|
@Column(name = "numero_e164", nullable = false, length = 20)
|
||||||
|
private String numeroE164;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String tipo = "principal";
|
||||||
|
|
||||||
|
public void setLead(Lead lead) {
|
||||||
|
if (!Objects.equals(this.lead, lead)) {
|
||||||
|
this.lead = lead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.seconectai.leadscraper.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "usuarios")
|
||||||
|
public class Usuario {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 150)
|
||||||
|
private String nome;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 150)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "senha_hash", nullable = false)
|
||||||
|
private String senhaHash;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String papel = "admin";
|
||||||
|
|
||||||
|
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime criadoEm;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "criadoPor")
|
||||||
|
private List<Busca> buscas = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CnpjValidator {
|
||||||
|
|
||||||
|
public String normalize(String cnpjBruto) {
|
||||||
|
if (cnpjBruto == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String digits = cnpjBruto.replaceAll("\\D", "");
|
||||||
|
if (!digits.matches("\\d{14}") || digits.chars().distinct().count() == 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isValid(digits) ? digits : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValid(String cnpj) {
|
||||||
|
int firstDigit = calculateDigit(cnpj.substring(0, 12), new int[]{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
|
||||||
|
int secondDigit = calculateDigit(cnpj.substring(0, 12) + firstDigit, new int[]{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
|
||||||
|
return cnpj.equals(cnpj.substring(0, 12) + firstDigit + secondDigit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int calculateDigit(String base, int[] weights) {
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < weights.length; index++) {
|
||||||
|
sum += Character.digit(base.charAt(index), 10) * weights[index];
|
||||||
|
}
|
||||||
|
int remainder = sum % 11;
|
||||||
|
return remainder < 2 ? 0 : 11 - remainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DedupHashCalculator {
|
||||||
|
|
||||||
|
public String calculate(String nomeNormalizado, String telefoneE164) {
|
||||||
|
String input = nullToEmpty(nomeNormalizado) + "|" + nullToEmpty(telefoneE164);
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder builder = new StringBuilder(hash.length * 2);
|
||||||
|
for (byte value : hash) {
|
||||||
|
builder.append(String.format("%02x", value));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 indisponivel", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
public record LeadNormalizadoDTO(
|
||||||
|
String nomeFantasia,
|
||||||
|
String categoriaBruta,
|
||||||
|
Long ramoId,
|
||||||
|
String telefoneE164,
|
||||||
|
String telefoneBruto,
|
||||||
|
String cnpj,
|
||||||
|
String site,
|
||||||
|
String enderecoCompleto,
|
||||||
|
String placeId,
|
||||||
|
String hashDedup
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.scraper.LeadBrutoDTO;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LeadNormalizerService {
|
||||||
|
|
||||||
|
private final NomeNormalizer nomeNormalizer;
|
||||||
|
private final TelefoneNormalizer telefoneNormalizer;
|
||||||
|
private final CnpjValidator cnpjValidator;
|
||||||
|
private final RamoMapper ramoMapper;
|
||||||
|
private final DedupHashCalculator dedupHashCalculator;
|
||||||
|
|
||||||
|
public LeadNormalizerService(
|
||||||
|
NomeNormalizer nomeNormalizer,
|
||||||
|
TelefoneNormalizer telefoneNormalizer,
|
||||||
|
CnpjValidator cnpjValidator,
|
||||||
|
RamoMapper ramoMapper,
|
||||||
|
DedupHashCalculator dedupHashCalculator
|
||||||
|
) {
|
||||||
|
this.nomeNormalizer = nomeNormalizer;
|
||||||
|
this.telefoneNormalizer = telefoneNormalizer;
|
||||||
|
this.cnpjValidator = cnpjValidator;
|
||||||
|
this.ramoMapper = ramoMapper;
|
||||||
|
this.dedupHashCalculator = dedupHashCalculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeadNormalizadoDTO normalize(LeadBrutoDTO leadBruto) {
|
||||||
|
String nome = nomeNormalizer.normalize(leadBruto.nome());
|
||||||
|
String telefoneE164 = telefoneNormalizer.normalize(leadBruto.telefoneBruto());
|
||||||
|
String cnpj = cnpjValidator.normalize(null);
|
||||||
|
Long ramoId = ramoMapper.mapToRamoId(leadBruto.categoriaBruta()).orElse(null);
|
||||||
|
String hashDedup = dedupHashCalculator.calculate(nome, telefoneE164);
|
||||||
|
|
||||||
|
return new LeadNormalizadoDTO(
|
||||||
|
nome,
|
||||||
|
leadBruto.categoriaBruta(),
|
||||||
|
ramoId,
|
||||||
|
telefoneE164,
|
||||||
|
leadBruto.telefoneBruto(),
|
||||||
|
cnpj,
|
||||||
|
leadBruto.site(),
|
||||||
|
leadBruto.enderecoBruto(),
|
||||||
|
leadBruto.placeId(),
|
||||||
|
hashDedup
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class NomeNormalizer {
|
||||||
|
|
||||||
|
private static final Set<String> SIGLAS = Set.of("LTDA", "ME", "EIRELI", "S/A", "S.A.");
|
||||||
|
|
||||||
|
public String normalize(String nome) {
|
||||||
|
if (nome == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String cleaned = nome.codePoints()
|
||||||
|
.filter(this::isAllowedTextCodePoint)
|
||||||
|
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replaceAll("\\s+", " ");
|
||||||
|
if (cleaned.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] parts = cleaned.split(" ");
|
||||||
|
for (int index = 0; index < parts.length; index++) {
|
||||||
|
parts[index] = normalizeWord(parts[index]);
|
||||||
|
}
|
||||||
|
return String.join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeWord(String word) {
|
||||||
|
String upper = word.toUpperCase(Locale.ROOT);
|
||||||
|
if (SIGLAS.contains(upper)) {
|
||||||
|
return upper;
|
||||||
|
}
|
||||||
|
if (word.length() == 1) {
|
||||||
|
return word.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
return word.substring(0, 1).toUpperCase(Locale.ROOT)
|
||||||
|
+ word.substring(1).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedTextCodePoint(int codePoint) {
|
||||||
|
int type = Character.getType(codePoint);
|
||||||
|
if (type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String value = new String(Character.toChars(codePoint));
|
||||||
|
return Normalizer.normalize(value, Normalizer.Form.NFKC).codePoints()
|
||||||
|
.noneMatch(cp -> Character.getType(cp) == Character.OTHER_SYMBOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Ramo;
|
||||||
|
import com.seconectai.leadscraper.repository.RamoRepository;
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RamoMapper {
|
||||||
|
|
||||||
|
private final RamoRepository ramoRepository;
|
||||||
|
|
||||||
|
public RamoMapper(RamoRepository ramoRepository) {
|
||||||
|
this.ramoRepository = ramoRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Long> mapToRamoId(String categoriaBruta) {
|
||||||
|
String normalized = normalize(categoriaBruta);
|
||||||
|
if (normalized == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return ramoRepository.findAll().stream()
|
||||||
|
.filter(ramo -> aliasesContain(ramo, normalized))
|
||||||
|
.map(Ramo::getId)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean aliasesContain(Ramo ramo, String categoriaNormalizada) {
|
||||||
|
if (normalize(ramo.getNomeCanonico()).equals(categoriaNormalizada)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ramo.getAliases() != null && ramo.getAliases().stream()
|
||||||
|
.map(this::normalize)
|
||||||
|
.anyMatch(categoriaNormalizada::equals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{M}", "")
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.isBlank() ? null : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.seconectai.leadscraper.normalizer;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TelefoneNormalizer {
|
||||||
|
|
||||||
|
public String normalize(String telefoneBruto) {
|
||||||
|
if (telefoneBruto == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String digits = telefoneBruto.replaceAll("\\D", "");
|
||||||
|
if (digits.startsWith("00")) {
|
||||||
|
digits = digits.substring(2);
|
||||||
|
}
|
||||||
|
if (digits.startsWith("55")) {
|
||||||
|
digits = digits.substring(2);
|
||||||
|
}
|
||||||
|
if (!digits.matches("\\d{10,11}")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String ddd = digits.substring(0, 2);
|
||||||
|
String numero = digits.substring(2);
|
||||||
|
if (!ddd.matches("[1-9]{2}") || !(numero.length() == 8 || numero.length() == 9)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "+55" + ddd + numero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.seconectai.leadscraper.proxy;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class BackoffService {
|
||||||
|
|
||||||
|
private final ProxySettingsProperties properties;
|
||||||
|
private final AtomicInteger consecutiveBlocks = new AtomicInteger();
|
||||||
|
|
||||||
|
public BackoffService(ProxySettingsProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long registrarBloqueioECalcularEspera() {
|
||||||
|
int failures = consecutiveBlocks.incrementAndGet();
|
||||||
|
double multiplier = Math.pow(properties.backoff().multiplier(), failures - 1);
|
||||||
|
long calculated = (long) (properties.backoff().initialMs() * multiplier);
|
||||||
|
return Math.min(calculated, properties.backoff().maxMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registrarSucesso() {
|
||||||
|
consecutiveBlocks.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int limiteBuscasHoraPorProxy() {
|
||||||
|
return properties.limiteBuscasHora();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.seconectai.leadscraper.proxy;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Page;
|
||||||
|
import com.seconectai.leadscraper.domain.Proxy;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusProxy;
|
||||||
|
import com.seconectai.leadscraper.repository.ProxyRepository;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class BloqueioDetector {
|
||||||
|
|
||||||
|
private final ProxyRepository proxyRepository;
|
||||||
|
private final ProxySettingsProperties properties;
|
||||||
|
|
||||||
|
public BloqueioDetector(ProxyRepository proxyRepository, ProxySettingsProperties properties) {
|
||||||
|
this.proxyRepository = proxyRepository;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBlocked(Page page) {
|
||||||
|
String content = page.content().toLowerCase(Locale.ROOT);
|
||||||
|
return content.contains("unusual traffic")
|
||||||
|
|| content.contains("trafego incomum")
|
||||||
|
|| content.contains("tráfego incomum")
|
||||||
|
|| content.contains("captcha")
|
||||||
|
|| content.contains("/sorry/");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void registrarBloqueio(Proxy proxy) {
|
||||||
|
if (proxy == null || proxy.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
|
||||||
|
int total = atual.getTotalBloqueios() == null ? 1 : atual.getTotalBloqueios() + 1;
|
||||||
|
atual.setTotalBloqueios(total);
|
||||||
|
atual.setEmUso(false);
|
||||||
|
atual.setStatus(total >= properties.maxBloqueios() ? StatusProxy.bloqueado : StatusProxy.degradado);
|
||||||
|
proxyRepository.save(atual);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.seconectai.leadscraper.proxy;
|
||||||
|
|
||||||
|
public class BloqueioException extends RuntimeException {
|
||||||
|
|
||||||
|
public BloqueioException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.seconectai.leadscraper.proxy;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Proxy;
|
||||||
|
import com.seconectai.leadscraper.repository.ProxyRepository;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ProxyPoolService {
|
||||||
|
|
||||||
|
private final ProxyRepository proxyRepository;
|
||||||
|
|
||||||
|
public ProxyPoolService(ProxyRepository proxyRepository) {
|
||||||
|
this.proxyRepository = proxyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<Proxy> reservarDisponivel() {
|
||||||
|
Optional<Proxy> proxy = proxyRepository.findNextAvailableForUpdate();
|
||||||
|
proxy.ifPresent(disponivel -> {
|
||||||
|
disponivel.setEmUso(true);
|
||||||
|
proxyRepository.save(disponivel);
|
||||||
|
});
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void liberar(Proxy proxy) {
|
||||||
|
if (proxy == null || proxy.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
|
||||||
|
atual.setEmUso(false);
|
||||||
|
atual.setUltimaUtilizacao(OffsetDateTime.now());
|
||||||
|
proxyRepository.save(atual);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.seconectai.leadscraper.proxy;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "scraper.proxy")
|
||||||
|
public record ProxySettingsProperties(
|
||||||
|
int maxBloqueios,
|
||||||
|
int limiteBuscasHora,
|
||||||
|
Backoff backoff
|
||||||
|
) {
|
||||||
|
|
||||||
|
public record Backoff(
|
||||||
|
long initialMs,
|
||||||
|
long maxMs,
|
||||||
|
double multiplier
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.seconectai.leadscraper.repository;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Busca;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusBusca;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
|
||||||
|
public interface BuscaRepository extends JpaRepository<Busca, Long>, JpaSpecificationExecutor<Busca> {
|
||||||
|
|
||||||
|
List<Busca> findByStatus(StatusBusca status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.seconectai.leadscraper.repository;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.HistoricoContato;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface HistoricoContatoRepository extends JpaRepository<HistoricoContato, Long> {
|
||||||
|
|
||||||
|
List<HistoricoContato> findByLeadId(Long leadId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.seconectai.leadscraper.repository;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Lead;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusLead;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
public interface LeadRepository extends JpaRepository<Lead, Long>, JpaSpecificationExecutor<Lead> {
|
||||||
|
|
||||||
|
Optional<Lead> findByHashDedup(String hashDedup);
|
||||||
|
|
||||||
|
Optional<Lead> findByCnpj(String cnpj);
|
||||||
|
|
||||||
|
Optional<Lead> findByPlaceId(String placeId);
|
||||||
|
|
||||||
|
List<Lead> findByStatus(StatusLead status);
|
||||||
|
|
||||||
|
List<Lead> findByBuscaId(Long buscaId);
|
||||||
|
|
||||||
|
long countByBuscaId(Long buscaId);
|
||||||
|
|
||||||
|
long countByTelefoneE164IsNotNull();
|
||||||
|
|
||||||
|
long countByBuscaIdAndTelefoneE164IsNotNull(Long buscaId);
|
||||||
|
|
||||||
|
@Query("select count(distinct lead.telefoneE164) from Lead lead where lead.telefoneE164 is not null and lead.telefoneE164 <> ''")
|
||||||
|
long countDistinctTelefonesCapturados();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.seconectai.leadscraper.repository;
|
||||||
|
|
||||||
|
import com.seconectai.leadscraper.domain.Proxy;
|
||||||
|
import com.seconectai.leadscraper.domain.StatusProxy;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
public interface ProxyRepository extends JpaRepository<Proxy, Long> {
|
||||||
|
|
||||||
|
List<Proxy> findByStatus(StatusProxy status);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
select * from proxies
|
||||||
|
where status = 'ativo'
|
||||||
|
and coalesce(em_uso, false) = false
|
||||||
|
order by ultima_utilizacao asc nulls first, id asc
|
||||||
|
for update skip locked
|
||||||
|
limit 1
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Optional<Proxy> findNextAvailableForUpdate();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user