Initial project import

This commit is contained in:
Tiago
2026-09-29 12:12:31 -03:00
commit c435bb5e23
120 changed files with 5371 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.m2/
target/
.git/
.env
uploads/
+19
View File
@@ -0,0 +1,19 @@
# Copie para .env e ajuste os valores antes de subir com Docker Compose.
# Senha do usuario app no PostgreSQL.
DB_PASSWORD=troque-esta-senha
# Hash da senha master. O Prompt 04 define o algoritmo final de verificacao.
MASTER_PASSWORD_HASH=hash-da-senha-master
# Segredo usado para assinar JWTs a partir do Prompt 04.
JWT_SECRET=troque-por-um-segredo-longo
ABACATEPAY_API_KEY=
ABACATEPAY_WEBHOOK_SECRET=dev_webhook_secret
APP_PUBLIC_URL=http://localhost:8080
# Chave global do servidor Evolution API. Sem valor, o envio WhatsApp usa fallback local/mock.
EVOLUTION_GLOBAL_API_KEY=
+19
View File
@@ -0,0 +1,19 @@
# Copie este arquivo para .env no servidor de producao e ajuste os segredos.
DB_PASSWORD=troque-por-uma-senha-forte
MASTER_PASSWORD_HASH=cole-o-hash-da-senha-master
JWT_SECRET=troque-por-um-segredo-longo-e-aleatorio
APP_PUBLIC_URL=https://condominio.seconectai.com.br
ABACATEPAY_API_KEY=
ABACATEPAY_WEBHOOK_SECRET=troque-por-um-segredo-do-webhook
# Chave global da Evolution API. Use valor forte e igual ao configurado no app.
EVOLUTION_GLOBAL_API_KEY=troque-por-uma-chave-forte
EVOLUTION_API_URL=http://evolution-api:8080
EVOLUTION_PUBLIC_URL=https://condominio.seconectai.com.br/evolution
# Use esta variável se DB_PASSWORD tiver caracteres especiais como %, #, @, : ou /.
# Nesse caso, informe a senha URL-encoded. Exemplo: % vira %25 e # vira %23.
# EVOLUTION_DATABASE_CONNECTION_URI=postgresql://app:SENHA_URL_ENCODED@postgres:5432/evolution
+9
View File
@@ -0,0 +1,9 @@
.m2/
target/
.env
uploads/
.env.*.local
*.log
.DS_Store
.idea/
.vscode/
+11
View File
@@ -0,0 +1,11 @@
condominio.seconectai.com.br {
encode gzip zstd
handle_path /evolution/* {
reverse_proxy evolution-api:8080
}
handle {
reverse_proxy app:8080
}
}
+89
View File
@@ -0,0 +1,89 @@
# Deploy em condominio.seconectai.com.br
Servidor alvo: `45.13.59.110`
Domínio: `condominio.seconectai.com.br`
## 1. DNS
No painel DNS do domínio, crie/ajuste:
```text
Tipo: A
Nome: condominio
Valor: 45.13.59.110
TTL: 300 ou automático
```
Aguarde a propagação e confirme:
```bash
dig +short condominio.seconectai.com.br
```
Deve retornar `45.13.59.110`.
## 2. Servidor
Instale Docker e Docker Compose Plugin no servidor. Libere no firewall:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp
sudo ufw enable
```
## 3. Código e variáveis
No servidor, envie ou clone o projeto. Dentro da pasta do projeto:
```bash
cp .env.production.example .env
nano .env
```
Preencha senhas e chaves reais. Não suba `.env` para Git.
## 4. Subir produção
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
Acompanhe logs:
```bash
docker compose -f docker-compose.prod.yml logs -f app caddy
```
Caddy emitirá HTTPS automaticamente para `https://condominio.seconectai.com.br`.
## 5. Testes
```bash
curl -I https://condominio.seconectai.com.br
curl https://condominio.seconectai.com.br/health
```
Resposta esperada do health:
```json
{"status":"ok"}
```
## 6. Webhook AbacatePay
Configure no AbacatePay:
```text
https://condominio.seconectai.com.br/api/master/webhooks/abacatepay?webhookSecret=SEU_SEGREDO
```
Use o mesmo valor de `ABACATEPAY_WEBHOOK_SECRET`.
## 7. Observações
- Em produção, Postgres e Redis não ficam publicados na internet.
- O app fica atrás do Caddy, com HTTPS automático.
- Uploads ficam em `./uploads` no servidor.
- Faça backup dos volumes Docker e da pasta `uploads`.
+11
View File
@@ -0,0 +1,11 @@
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -q clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/condocontab-0.1.0.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+49
View File
@@ -0,0 +1,49 @@
# SeConectai Condomínios
Sistema de cadastro de referência para escritórios de contabilidade de condomínio.
## Stack
- Java 21 puro com `com.sun.net.httpserver.HttpServer`
- JDBC puro
- PostgreSQL 16
- Frontend HTML/CSS/JS puro servido pelo processo Java
- Docker Compose
## Rodando com Docker
1. Copie o arquivo de exemplo:
```bash
cp .env.example .env
```
2. Ajuste os valores no `.env`.
3. Suba os serviços:
```bash
docker compose up --build
```
4. Teste:
```bash
curl http://localhost:8080/health
```
Resposta esperada:
```json
{"status":"ok"}
```
A página inicial fica em `http://localhost:8080/`.
## Build local
```bash
mvn clean package
```
O JAR executável é gerado em `target/condocontab-0.1.0.jar`.
@@ -0,0 +1,215 @@
# Sistema de Contabilidade de Condomínios — Arquitetura v3.0 (FINAL)
> Substitui v2.0. Escopo drasticamente simplificado: o sistema é um **cadastro de referência** para o escritório — não um ERP de condomínio. Sem reservas, sem moradores, sem financeiro do condomínio, sem ocorrências, sem fornecedores, sem votação. Só: condomínios, contatos (síndico/subsíndico/conselheiro), funcionários vinculados, áreas comuns, assembleias e avisos/alertas com documentos anexados.
**Stack:** Java puro (JDBC) + `HttpServer` do JDK · PostgreSQL 16 (1 database por empresa) · Frontend HTML/CSS/JS puro no mesmo processo · Docker Compose.
---
## 1. Diagrama — `db_master` (sem mudança desde v2.0)
```mermaid
erDiagram
EMPRESAS ||--o{ FUNCIONARIOS : tem
EMPRESAS ||--o{ API_KEYS : possui
EMPRESAS ||--o{ EMPRESA_FUNCIONALIDADES : libera
FUNCIONALIDADES ||--o{ EMPRESA_FUNCIONALIDADES : referenciada_em
EMPRESAS ||--o{ EVOLUTION_INSTANCIAS : cadastra
PLANOS ||--o{ PLANO_FUNCIONALIDADES : inclui
FUNCIONALIDADES ||--o{ PLANO_FUNCIONALIDADES : referenciada_em
PLANOS ||--o{ ASSINATURAS : usado_em
EMPRESAS ||--o{ ASSINATURAS : possui
EMPRESAS ||--o{ PERIODOS_USO : tem
PERIODOS_USO ||--o{ PAGAMENTOS : gera
EMPRESAS ||--o{ LOGS_ENVIO_AVISOS : gera
EMPRESAS ||--o{ LEMBRETES_COBRANCA : recebe
```
Schema igual ao documento v2.0 (empresas, funcionarios, api_keys, funcionalidades, empresa_funcionalidades, evolution_instancias, configuracoes_plataforma, planos, plano_funcionalidades, assinaturas, periodos_uso, pagamentos, logs_envio_avisos, lembretes_cobranca) — nada disso mudou.
---
## 2. Diagrama — `db_empresa_<slug>` (NOVO, bem mais simples)
```mermaid
erDiagram
CONDOMINIOS ||--o{ CONTATO_CONDOMINIO : vincula
CONTATOS ||--o{ CONTATO_CONDOMINIO : vinculado_a
CONDOMINIOS ||--o{ FUNCIONARIO_CONDOMINIO : atendido_por
CONDOMINIOS ||--o{ ESPACOS_COMUNS : possui
CONDOMINIOS ||--o{ ASSEMBLEIAS : realiza
CONDOMINIOS ||--o{ AVISOS : publica
ASSEMBLEIAS ||--o{ AVISOS : gera
AVISOS ||--o{ AVISO_DOCUMENTOS : anexa
DOCUMENTOS ||--o{ AVISO_DOCUMENTOS : anexado_em
AVISOS ||--o{ AVISO_DESTINATARIOS : enviado_para
CONDOMINIOS ||--o{ DOCUMENTOS : arquiva
```
### Schema
```sql
CREATE TABLE condominios (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
cnpj VARCHAR(18) UNIQUE,
endereco VARCHAR(255),
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
-- pessoa física (síndico/subsíndico/conselheiro) — cadastrada uma vez,
-- pode ser vinculada a mais de um condomínio
CREATE TABLE contatos (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
telefone VARCHAR(20), -- WhatsApp
email VARCHAR(150),
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
-- vínculo N:N entre contato e condomínio, com o papel que exerce ali
CREATE TABLE contato_condominio (
contato_id BIGINT NOT NULL REFERENCES contatos(id),
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(20) NOT NULL, -- sindico, subsindico, conselheiro
data_inicio_mandato DATE,
data_fim_mandato DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
PRIMARY KEY (contato_id, condominio_id, tipo)
);
-- "síndico atual" de um condomínio = SELECT ... WHERE condominio_id=X AND tipo='sindico' AND status='ativo'
-- funcionário do escritório (cadastro fica em db_master.funcionarios) vinculado
-- a quais condomínios ele cuida — também recebe avisos/alertas desses condomínios
CREATE TABLE funcionario_condominio (
funcionario_id BIGINT NOT NULL, -- referência a db_master.funcionarios, sem FK cross-db
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
PRIMARY KEY (funcionario_id, condominio_id)
);
CREATE TABLE espacos_comuns (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
nome VARCHAR(100) NOT NULL, -- salão de festas, churrasqueira, quadra...
descricao TEXT,
valor_utilizacao_centavos BIGINT,
indice_correcao VARCHAR(30), -- IGPM, IPCA, definido em assembleia...
data_ultimo_reajuste DATE,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE assembleias (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(20) NOT NULL, -- ordinaria, extraordinaria
data_hora TIMESTAMP NOT NULL,
local VARCHAR(150),
pauta TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'agendada', -- agendada, realizada, cancelada
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE documentos (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(40), -- ata, convocacao, regimento, convencao, contrato, outro
nome_arquivo VARCHAR(255) NOT NULL,
caminho_armazenamento VARCHAR(500) NOT NULL,
versao INT NOT NULL DEFAULT 1,
enviado_por BIGINT, -- id em db_master.funcionarios
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE avisos (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
assembleia_id BIGINT REFERENCES assembleias(id), -- opcional: aviso sobre uma assembleia específica
tipo VARCHAR(20) NOT NULL DEFAULT 'aviso', -- aviso, alerta
titulo VARCHAR(150) NOT NULL,
mensagem TEXT NOT NULL,
data_evento TIMESTAMP, -- data/hora do evento relacionado (se houver)
data_publicacao TIMESTAMP NOT NULL DEFAULT now(),
data_expiracao TIMESTAMP,
prioridade VARCHAR(10) DEFAULT 'normal', -- baixa, normal, alta, urgente
enviar_whatsapp BOOLEAN NOT NULL DEFAULT false,
enviar_email BOOLEAN NOT NULL DEFAULT false,
status_envio VARCHAR(20) NOT NULL DEFAULT 'nao_enviado', -- nao_enviado, enviado, falhou
criado_por_funcionario BIGINT, -- id em db_master.funcionarios
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE aviso_documentos (
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
documento_id BIGINT NOT NULL REFERENCES documentos(id),
PRIMARY KEY (aviso_id, documento_id)
);
-- destinatário pode ser um contato (síndico/subsíndico/conselheiro) OU um funcionário do escritório
CREATE TABLE aviso_destinatarios (
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
tipo_destinatario VARCHAR(20) NOT NULL, -- contato, funcionario
destinatario_id BIGINT NOT NULL, -- contatos.id ou db_master.funcionarios.id, conforme tipo
PRIMARY KEY (aviso_id, tipo_destinatario, destinatario_id)
);
CREATE TABLE log_auditoria (
id BIGSERIAL PRIMARY KEY,
funcionario_id BIGINT,
acao VARCHAR(20) NOT NULL, -- create, update, delete
tabela_afetada VARCHAR(60) NOT NULL,
registro_id BIGINT,
dados_anteriores JSONB,
dados_novos JSONB,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
```
**Removido em relação à v2.0:** `contatos_condominio` (virou `contatos` + `contato_condominio`), `reservas_area_comum`, `regras_cobranca`, `historico_reajustes`, `prestacoes_contas`, `lancamentos_financeiros`, `ocorrencias`, `fornecedores`, `votacoes`.
---
## 3. Fluxo do exemplo que você deu
"Assembleia dia 23/11/2026 às 18:00 → vincular documentos pertinentes":
1. Cadastra em `assembleias`: `condominio_id`, `tipo='ordinaria'`, `data_hora='2026-11-23 18:00'`, `pauta`.
2. Cria um `avisos` com `assembleia_id` apontando pra ela, `tipo='aviso'`, `data_evento` = mesma data/hora.
3. Anexa os documentos (convocação, pauta detalhada) via `aviso_documentos`.
4. Escolhe destinatários em `aviso_destinatarios` (síndico, subsíndico, conselheiros daquele condomínio + funcionários vinculados) — ou marca "todos" como padrão sugerido.
5. Marca `enviar_whatsapp`/`enviar_email` e dispara.
---
## 4. Autenticação, financeiro/AbacatePay, dashboard, catálogo de funcionalidades
Sem mudanças em relação à v2.0 — seguem valendo:
- 3 camadas de auth (master / escritório / API Key)
- Módulo financeiro do escritório (assinaturas, períodos de uso, pagamentos PIX via AbacatePay, bloqueio automático)
- Menu do dashboard (Master: Clientes/Planos/Financeiro/Configurações · Cliente: Cadastro Condomínio/Financeiro/Configurações)
- Catálogo de `funcionalidades` (whatsapp, e-mail, API, exportação, auditoria etc.)
---
## 5. Endpoints ajustados
```
CRUD /api/condominios
CRUD /api/condominios/{id}/contatos (vincula/desvincula tipo=sindico|subsindico|conselheiro)
CRUD /api/condominios/{id}/funcionarios-vinculados
CRUD /api/condominios/{id}/espacos-comuns
CRUD /api/condominios/{id}/assembleias
CRUD /api/condominios/{id}/documentos
POST /api/condominios/{id}/avisos (body: assembleia_id?, documentos: [id,...], destinatarios: [{tipo, id}, ...])
GET /api/condominios/{id}/avisos/{id}
```
**App externo (API Key)**
```
GET /api/external/condominios/{id}/resumo
→ síndico/subsíndico/conselheiros atuais, próxima assembleia, avisos ativos, espaços comuns
```
---
## 6. Próximos passos
Escopo definitivo fechado. Posso começar os prompt files numerados agora (`01-setup-projeto-docker.md` em diante)?
+115
View File
@@ -0,0 +1,115 @@
# Prompt 01 — Setup do projeto, Docker e HttpServer básico
## Contexto
Início do zero. Projeto: sistema de cadastro de referência para escritórios de contabilidade de condomínio (multi-tenant, 1 database PostgreSQL por empresa + 1 `db_master`). Stack: **Java puro** (sem framework), JDBC puro, `com.sun.net.httpserver.HttpServer` (built-in do JDK) para HTTP, PostgreSQL 16, Docker Compose. Frontend HTML/CSS/JS puro, servido pelo mesmo processo Java.
Este é o primeiro prompt da sequência. Não existe nenhum código ainda.
## Objetivo
Criar o esqueleto do projeto: estrutura de pastas, Dockerfile, docker-compose.yml, conexão JDBC básica com o `db_master`, e um `HttpServer` que sobe, responde a um healthcheck, e já tem a estrutura de roteamento pronta para os próximos prompts registrarem handlers.
## Especificação
### 1. Estrutura de pastas
```
condominio-contabilidade/
├── src/main/java/com/seconectai/condocontab/
│ ├── Main.java
│ ├── config/
│ │ ├── AppConfig.java (lê variáveis de ambiente)
│ │ └── DataSourceProvider.java (cria/gerencia conexões JDBC)
│ ├── http/
│ │ ├── Router.java (registra rotas -> handlers)
│ │ └── StaticFileHandler.java (serve arquivos de webapp/)
│ ├── master/ (vazio por enquanto, prompts futuros)
│ ├── tenant/ (vazio por enquanto)
│ ├── auth/ (vazio por enquanto)
│ ├── integracao/ (vazio por enquanto)
│ └── db/
│ └── MigrationRunner.java (executa .sql de resources/migrations)
├── src/main/resources/
│ └── migrations/
│ ├── master/ (vazio por enquanto — prompt 02)
│ └── tenant/ (vazio por enquanto — prompt 03)
├── src/main/webapp/
│ └── index.html (placeholder: "Sistema no ar")
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
### 2. `AppConfig.java`
Lê das variáveis de ambiente (com valores default sensatos para dev local):
- `DB_HOST`, `DB_PORT` (5432), `DB_USER`, `DB_PASSWORD`, `DB_MASTER_NAME` (db_master)
- `HTTP_PORT` (8080)
- `MASTER_PASSWORD_HASH` (usado só no prompt 04, mas já lido aqui)
- `JWT_SECRET` (usado só no prompt 04)
Falhar de forma clara (log + exit) se uma variável obrigatória estiver ausente.
### 3. `DataSourceProvider.java`
- Sem HikariCP nem pool de terceiros: implementar um pool simples próprio (uma `BlockingQueue<Connection>` com tamanho configurável, ex: 10 conexões) OU usar `DriverManager.getConnection()` direto por enquanto (mais simples) com um método `getMasterConnection()`. Documentar no código que o cache de DataSource por tenant vem no prompt 03/06.
- Testar a conexão na subida da aplicação (`SELECT 1`) e logar sucesso/erro.
### 4. `MigrationRunner.java`
- Lê todos os arquivos `.sql` de um diretório de resources (ordenados por nome, prefixo numérico tipo `001_xxx.sql`, `002_xxx.sql`), executa os que ainda não rodaram.
- Cria uma tabela de controle `schema_migrations (id, nome_arquivo, executado_em)` no banco de destino, se não existir.
- Método `runMigrations(Connection conn, String diretorioResources)` reutilizável tanto para `db_master` quanto para cada `db_empresa_<slug>` (usado nos prompts 02 e 03).
### 5. `Router.java` + `Main.java`
- `HttpServer` escutando em `HTTP_PORT`, com um `Router` que mapeia `path -> handler` (pode ser simples: um `Map<String, HttpHandler>` com suporte a prefixo, tipo `/api/**` cai num handler, senão cai no `StaticFileHandler`).
- Endpoint `GET /health` retornando `200 OK` com `{"status":"ok"}` em JSON puro (sem lib de JSON externa — escrever um helper simples `Json.write(Map<String,Object>)` que serializa tipos básicos; pode ser reaproveitado nos próximos prompts).
- `StaticFileHandler` serve os arquivos de `src/main/webapp/` (ou do diretório equivalente dentro do JAR/imagem) para qualquer rota que não comece com `/api/`.
### 6. Dockerfile (multi-stage)
- Stage 1: build com `maven:3.9-eclipse-temurin-21` (ou Gradle, se preferir — usar Maven por simplicidade), gera o JAR.
- Stage 2: `eclipse-temurin:21-jre`, copia o JAR + a pasta `webapp/`, expõe `8080`, `ENTRYPOINT ["java","-jar","app.jar"]`.
### 7. docker-compose.yml
```yaml
version: "3.9"
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: db_master
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
app:
build: .
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD}
DB_MASTER_NAME: db_master
HTTP_PORT: 8080
MASTER_PASSWORD_HASH: ${MASTER_PASSWORD_HASH}
JWT_SECRET: ${JWT_SECRET}
depends_on:
- postgres
ports:
- "8080:8080"
volumes:
pgdata:
```
### 8. `.env.example`
Listar todas as variáveis usadas, com comentário de exemplo (sem valores reais sensíveis).
## Critérios de aceite
- [ ] `docker compose up` sobe `postgres` e `app` sem erro.
- [ ] `GET http://localhost:8080/health` retorna `200` com `{"status":"ok"}`.
- [ ] `GET http://localhost:8080/` retorna o `index.html` placeholder.
- [ ] Log de startup mostra "Conexão com db_master OK".
- [ ] `MigrationRunner` roda sem erro mesmo sem nenhum arquivo `.sql` ainda (diretório vazio = no-op).
- [ ] Projeto builda com `mvn clean package` (ou `gradle build`) sem dependências externas além do driver JDBC do PostgreSQL (`org.postgresql:postgresql`).
- [ ] README.md com instruções de como rodar localmente (`docker compose up`, variáveis de `.env`).
## Ao concluir
Atualizar `10-tracker-progresso.md` marcando o Prompt 01 como concluído, com a data e um resumo de 1-2 linhas do que foi feito. Não avançar para o Prompt 02 sem esse tracker atualizado.
+82
View File
@@ -0,0 +1,82 @@
# Prompt 02 — Migrations do `db_master`
## Contexto
O Prompt 01 já entregou: projeto Java rodando em Docker, `HttpServer` no ar, `MigrationRunner` pronto pra rodar arquivos `.sql`, `DataSourceProvider` com conexão ao `db_master`. **Leia o tracker (`10-tracker-progresso.md`) antes de começar** para confirmar que o Prompt 01 está marcado como concluído.
Este prompt cria todo o schema do banco de catálogo (`db_master`): empresas, funcionários, planos, funcionalidades, financeiro (assinaturas/pagamentos), logs.
## Objetivo
Criar os arquivos de migration SQL em `src/main/resources/migrations/master/`, rodá-los automaticamente na subida da aplicação (se `db_master` ainda não tiver as tabelas), e criar as classes Java de acesso a dados (DAO) básicas para essas tabelas — sem endpoints HTTP ainda (isso vem no Prompt 05).
## Especificação
### 1. Migrations (uma tabela por arquivo, numeração sequencial)
Criar em `src/main/resources/migrations/master/`:
- `001_empresas.sql`
- `002_funcionarios.sql`
- `003_api_keys.sql`
- `004_funcionalidades.sql`
- `005_empresa_funcionalidades.sql`
- `006_evolution_instancias.sql`
- `007_configuracoes_plataforma.sql`
- `008_planos.sql`
- `009_plano_funcionalidades.sql`
- `010_assinaturas.sql`
- `011_periodos_uso.sql`
- `012_pagamentos.sql`
- `013_logs_envio_avisos.sql`
- `014_lembretes_cobranca.sql`
- `015_seed_funcionalidades.sql` (INSERT do catálogo — ver abaixo)
Usar exatamente o DDL do documento de arquitetura v3.0 (seção 1), tabela por tabela — não inventar colunas novas. Reproduzo aqui as tabelas que essa fase cria (copie fielmente, incluindo tipos e defaults):
- `empresas(id, nome, cnpj, slug, database_name, status, criado_em)`
- `funcionarios(id, empresa_id, nome, email, senha_hash, cargo, status, criado_em)`
- `api_keys(id, empresa_id, key_hash, descricao, ativa, criado_em, revogada_em)`
- `funcionalidades(id, chave, nome, descricao)`
- `empresa_funcionalidades(empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)` — PK composta
- `evolution_instancias(id, empresa_id, instance_url, instance_token_enc, numero_whatsapp, status, solicitado_em, aprovado_em, aprovado_por)`
- `configuracoes_plataforma(chave, valor, atualizado_em)` — PK = chave
- `planos(id, nome, valor_mensal_centavos, descricao, ativo, criado_em)`
- `plano_funcionalidades(plano_id, funcionalidade_id)` — PK composta
- `assinaturas(id, empresa_id, plano_id, valor_mensal_centavos, dia_vencimento, status, criado_em)`
- `periodos_uso(id, empresa_id, mes_referencia, status, data_liberacao, data_bloqueio)` — UNIQUE(empresa_id, mes_referencia)
- `pagamentos(id, empresa_id, periodo_uso_id, valor_centavos, abacatepay_charge_id, qr_code_payload, qr_code_base64, status, criado_em, pago_em)`
- `logs_envio_avisos(id, empresa_id, condominio_id, aviso_id, contato_id, contato_nome, canal, status, erro_detalhe, criado_em)`
- `lembretes_cobranca(id, empresa_id, periodo_uso_id, canal, enviado_por, criado_em)`
Seed (`015_seed_funcionalidades.sql`): usar o catálogo completo (MVP + Fase 2) da seção 6 do documento de arquitetura v3.0 — 24 chaves MVP + 6 chaves Fase 2. Copiar os `INSERT INTO funcionalidades` literalmente.
### 2. Índices recomendados
Adicionar (na própria migration de cada tabela, ou em um `016_indices.sql`):
```sql
CREATE INDEX idx_funcionarios_empresa ON funcionarios(empresa_id);
CREATE INDEX idx_api_keys_empresa ON api_keys(empresa_id);
CREATE INDEX idx_evolution_empresa ON evolution_instancias(empresa_id);
CREATE INDEX idx_assinaturas_empresa ON assinaturas(empresa_id);
CREATE INDEX idx_periodos_uso_empresa ON periodos_uso(empresa_id);
CREATE INDEX idx_pagamentos_empresa ON pagamentos(empresa_id);
CREATE INDEX idx_pagamentos_periodo ON pagamentos(periodo_uso_id);
CREATE INDEX idx_logs_envio_empresa ON logs_envio_avisos(empresa_id, status);
```
### 3. Rodar migrations na subida
No `Main.java`, após confirmar conexão com `db_master`, chamar `MigrationRunner.runMigrations(masterConnection, "migrations/master")`.
### 4. DAOs básicos (sem endpoints ainda)
Criar em `com.seconectai.condocontab.master.dao`:
- `EmpresaDao` (métodos: `inserir`, `buscarPorId`, `listarTodas`, `atualizarStatus`)
- `FuncionarioDao` (`inserir`, `buscarPorEmail`, `listarPorEmpresa`)
- `FuncionalidadeDao` (`listarTodas`, `buscarPorChave`)
Usar JDBC puro (`PreparedStatement`), sem ORM. Todo valor monetário como `long` (centavos) no Java.
## Critérios de aceite
- [ ] `docker compose up` roda as 15 migrations em ordem, sem erro, na primeira subida.
- [ ] Subir o container uma segunda vez NÃO tenta re-executar migrations já aplicadas (tabela `schema_migrations` funcionando).
- [ ] `SELECT * FROM funcionalidades` retorna as 30 linhas do seed (24 MVP + 6 Fase 2).
- [ ] `EmpresaDao.inserir` + `EmpresaDao.buscarPorId` funcionam via um teste manual (pode ser um `main` temporário ou teste JUnit simples).
- [ ] Nenhuma tabela usa `DELETE` como estratégia de remoção — todas têm campo de `status` para inativação lógica onde fizer sentido (já está no DDL).
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 02 concluído, data, resumo. Confirmar que Prompt 01 segue marcado como concluído antes de finalizar.
+67
View File
@@ -0,0 +1,67 @@
# Prompt 03 — Migrations do `db_empresa_<slug>` (tenant) e criação dinâmica de database
## Contexto
Prompts 01 e 02 concluídos: projeto no ar, `db_master` com schema completo. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt cria: (a) o schema que roda dentro de cada `db_empresa_<slug>`, (b) o mecanismo que cria um novo database Postgres + roda essas migrations quando uma empresa nova é cadastrada, e (c) o cache de conexões por tenant.
## Objetivo
Implementar `TenantDataSourceProvider` (cria/reaproveita conexão por `empresa_id`) e `TenantProvisioner` (cria o database físico + roda migrations quando uma empresa é criada), e as migrations do schema operacional (condomínios, contatos, avisos etc. da arquitetura v3.0).
## Especificação
### 1. Migrations — `src/main/resources/migrations/tenant/`
Criar, na ordem, usando o DDL exato da seção 2 do documento de arquitetura v3.0:
- `001_condominios.sql`
- `002_contatos.sql`
- `003_contato_condominio.sql`
- `004_funcionario_condominio.sql`
- `005_espacos_comuns.sql`
- `006_assembleias.sql`
- `007_documentos.sql`
- `008_avisos.sql`
- `009_aviso_documentos.sql`
- `010_aviso_destinatarios.sql`
- `011_log_auditoria.sql`
- `012_indices.sql`:
```sql
CREATE INDEX idx_contato_condominio_condominio ON contato_condominio(condominio_id);
CREATE INDEX idx_contato_condominio_contato ON contato_condominio(contato_id);
CREATE INDEX idx_funcionario_condominio_condominio ON funcionario_condominio(condominio_id);
CREATE INDEX idx_espacos_condominio ON espacos_comuns(condominio_id);
CREATE INDEX idx_assembleias_condominio ON assembleias(condominio_id, data_hora);
CREATE INDEX idx_documentos_condominio ON documentos(condominio_id);
CREATE INDEX idx_avisos_condominio ON avisos(condominio_id, data_publicacao);
CREATE INDEX idx_avisos_assembleia ON avisos(assembleia_id);
CREATE INDEX idx_aviso_destinatarios_aviso ON aviso_destinatarios(aviso_id);
```
**Importante:** confira o DDL contra o documento v3.0 antes de escrever — `avisos.assembleia_id` é opcional (nullable), `aviso_destinatarios.destinatario_id` NÃO tem FK (referencia `contatos.id` OU `db_master.funcionarios.id` dependendo de `tipo_destinatario`), `funcionario_condominio.funcionario_id` também não tem FK (cross-database).
### 2. `TenantProvisioner`
Classe responsável por, dado um `nome` e `slug` de empresa:
1. Conectar no Postgres com um usuário administrativo (não o `app` comum) e rodar `CREATE DATABASE db_empresa_<slug>` (sanitizar o slug: só `[a-z0-9_]`, gerar erro claro se inválido).
2. Abrir conexão no novo database recém-criado.
3. Rodar `MigrationRunner.runMigrations(conn, "migrations/tenant")` nele.
4. Retornar sucesso/erro de forma que o chamador (Prompt 05, ao cadastrar empresa) possa tratar — se falhar no meio, o ideal é logar claramente e deixar o registro em `empresas.status` como algo como `provisionamento_pendente` (pode adicionar esse status à coluna livremente, é `VARCHAR`).
### 3. `TenantDataSourceProvider`
- Um `Map<Long empresaId, Connection>` (ou pool simples por tenant) em memória, populado sob demanda: na primeira requisição que precisa do tenant X, abre a conexão em `db_empresa_<database_name>` (buscando `database_name` na tabela `empresas` do master) e guarda no cache.
- Método `getConnection(long empresaId)` — se não estiver no cache, busca `database_name` no master, conecta, guarda, retorna.
- Não precisa de invalidação de cache sofisticada nesta fase — documentar como ponto de atenção futuro (ex: quando uma empresa é cancelada/bloqueada, talvez fechar a conexão do cache; deixar um método `invalidar(empresaId)` já pronto, mesmo que não seja chamado ainda).
### 4. DAOs básicos do tenant (sem endpoints ainda — vêm no Prompt 06)
Criar em `com.seconectai.condocontab.tenant.dao`, todos recebendo a `Connection` do tenant como parâmetro (não guardam estado):
- `CondominioDao` (`inserir`, `buscarPorId`, `listarTodos`)
- `ContatoDao` (`inserir`, `buscarPorId`)
- `ContatoCondominioDao` (`vincular`, `listarPorCondominio(condominioId)`, `buscarSindicoAtual(condominioId)` — query com `WHERE tipo='sindico' AND status='ativo'`)
## Critérios de aceite
- [ ] Rodar `TenantProvisioner.provisionar("Empresa Teste", "empresa-teste")` cria de fato um novo database `db_empresa_empresa_teste` no Postgres, com as 11 tabelas do schema tenant.
- [ ] Rodar de novo para outro slug cria outro database, isolado do primeiro (inserir um condomínio em um não aparece no outro).
- [ ] `TenantDataSourceProvider.getConnection(empresaId)` retorna conexão válida e reaproveita a mesma conexão em chamadas subsequentes (validar com log ou teste).
- [ ] Slug inválido (ex: com espaço ou maiúscula) é rejeitado com mensagem de erro clara, sem tentar criar o database.
- [ ] `ContatoCondominioDao.buscarSindicoAtual` retorna `null`/vazio quando não há síndico ativo, sem lançar exceção.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 03 concluído, data, resumo. Confirmar Prompts 01 e 02 seguem marcados.
+60
View File
@@ -0,0 +1,60 @@
# Prompt 04 — Autenticação: senha master, login de funcionário (JWT) e API Key
## Contexto
Prompts 01–03 concluídos: app no ar, `db_master` e `db_empresa` com schema completo, DAOs básicos, provisionamento de tenant funcionando. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa as 3 camadas de autenticação da arquitetura v3.0: master (senha única), funcionário do escritório (JWT), app externo (API Key).
## Objetivo
Implementar geração/validação de JWT (sem lib pesada — implementar HMAC-SHA256 manualmente ou usar uma lib mínima tipo `java-jwt`/`jjwt`, only se realmente necessário; preferir implementação própria já que o projeto é "Java puro"), hashing de senha (BCrypt — aqui vale trazer uma lib pequena e específica, tipo `at.favre.lib:bcrypt`, já que reimplementar bcrypt do zero não compensa), geração/validação de API Key, e os middlewares de autenticação usados pelos handlers HTTP dos próximos prompts.
## Especificação
### 1. Hash de senha
- Usar BCrypt (biblioteca `at.favre.lib:bcrypt`, única dependência externa além do driver JDBC e (se precisar) da lib de JWT).
- `PasswordHasher.hash(senha)` e `PasswordHasher.verificar(senha, hash)`.
### 2. JWT
- `JwtService.gerar(Map<String,Object> claims, long validadeSegundos)` → string do token.
- `JwtService.validar(String token)` → `Optional<Map<String,Object>> claims` (vazio se inválido/expirado).
- Claims para o token **master**: `{"escopo": "master"}`.
- Claims para o token de **funcionário**: `{"escopo": "empresa", "empresa_id": X, "funcionario_id": Y, "cargo": "..."}`.
- Assinatura HMAC-SHA256 usando `JWT_SECRET` (variável de ambiente já lida no Prompt 01).
- Validade sugerida: 8 horas para funcionário, 2 horas para master (reautenticação frequente por segurança, já que é a chave mestra do sistema).
### 3. API Key
- Geração: `ApiKeyService.gerar()` → retorna a chave em texto puro (mostrada **uma única vez** ao usuário) + o hash (SHA-256 é suficiente aqui, não precisa de bcrypt) que é salvo em `api_keys.key_hash`.
- Validação: `ApiKeyService.validar(String chaveRecebida)` → busca por hash no banco master, confere `ativa=true`, retorna o `empresa_id` vinculado (ou vazio se inválida/revogada).
### 4. Endpoints
```
POST /api/master/login body: {"senha": "..."}
→ confere hash contra MASTER_PASSWORD_HASH (env var); se ok, retorna {"token": "..."}
POST /api/auth/login body: {"email": "...", "senha": "..."}
→ busca funcionario por email (em qual empresa? funcionarios.email é único globalmente,
então a busca não precisa de empresa_id no body), confere senha, gera JWT de empresa
→ se funcionarios.status != 'ativo' OU empresas.status != 'ativa': erro 403 com mensagem clara
("cadastro desativado" ou "escritório com pendência financeira", respectivamente)
```
### 5. Middlewares (para uso nos próximos prompts)
Criar filtros/handlers wrapper reutilizáveis:
- `exigirMaster(HttpHandler)` — lê header `Authorization: Bearer <token>`, valida escopo=master, senão retorna 401.
- `exigirFuncionario(HttpHandler)` — idem, escopo=empresa; injeta `empresa_id`/`funcionario_id`/`cargo` no contexto da requisição (pode ser via `HttpExchange.setAttribute` ou um `ThreadLocal` simples).
- `exigirApiKey(HttpHandler)` — lê header `X-API-Key`, valida, injeta `empresa_id` no contexto.
### 6. Segurança básica
- Nunca logar senha, token completo ou API key em texto puro nos logs (logar no máximo os primeiros 6 caracteres + "...").
- Rate limiting simples no `/api/master/login` e `/api/auth/login` (ex: máximo 5 tentativas por IP a cada 5 minutos, em memória — não precisa de Redis nesta fase).
## Critérios de aceite
- [ ] `POST /api/master/login` com senha errada retorna 401; com senha certa retorna um JWT válido.
- [ ] Token master usado em `exigirFuncionario` é rejeitado (escopo errado) — e vice-versa.
- [ ] `POST /api/auth/login` com funcionário de empresa bloqueada (`empresas.status='bloqueada'`) retorna 403 com mensagem específica de pendência financeira.
- [ ] API Key gerada, salva com hash, validada corretamente; uma chave revogada (`ativa=false`) é rejeitada.
- [ ] 6ª tentativa de login em 5 minutos do mesmo IP é bloqueada temporariamente.
- [ ] Nenhum log do sistema imprime senha, token completo ou API key completa.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 04 concluído, data, resumo. Confirmar Prompts 01–03 seguem marcados.
+86
View File
@@ -0,0 +1,86 @@
# Prompt 05 — Painel Master: clientes, planos e funcionalidades
## Contexto
Prompts 01–04 concluídos: schema completo (master + tenant), provisionamento de tenant, autenticação (master/funcionário/API Key) funcionando. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa os endpoints do menu **Master → Clientes** e **Master → Planos**, incluindo o provisionamento de empresa nova (usa o `TenantProvisioner` do Prompt 03) e o toggle de funcionalidades por empresa.
## Objetivo
CRUD de empresas (clientes), CRUD de planos, ativação/desativação de funcionalidades por empresa, listagem de logs de envio com erro e listagem de clientes pagantes/inadimplentes.
Todos os endpoints deste prompt exigem `exigirMaster` (Prompt 04).
## Especificação
### 1. Clientes (empresas)
```
POST /api/master/empresas
body: {"nome": "...", "cnpj": "...", "slug": "..."}
→ valida slug único, cria registro em `empresas` (status inicial: 'provisionando'),
chama TenantProvisioner de forma síncrona (aceitável nesta fase — trocar por fila
assíncrona é uma melhoria futura, não bloquear o prompt por isso),
atualiza status para 'ativa' ao concluir (ou 'provisionamento_pendente' se falhar)
→ retorna o registro criado, incluindo se o provisionamento deu certo
GET /api/master/empresas
query params opcionais: ?status=ativa|bloqueada|cancelada
→ lista com nome, status, plano vinculado (join com assinaturas+planos), situação do
período de uso vigente (pago/pendente/bloqueado)
GET /api/master/empresas/{id}
PUT /api/master/empresas/{id} (editar nome/cnpj — NÃO permite editar slug/database_name)
```
### 2. Funcionários da empresa (cadastro pelo master também, já que "cadastro da empresa é feito pelo master")
```
POST /api/master/empresas/{id}/funcionarios
body: {"nome", "email", "senha" (texto puro, só nesta chamada — vira hash), "cargo"}
GET /api/master/empresas/{id}/funcionarios
```
*(Nota: o próprio escritório pode ter uma tela de gestão de funcionários mais pra frente, mas o cadastro inicial do primeiro funcionário é feito pelo master junto com a empresa, senão ninguém consegue logar.)*
### 3. Funcionalidades
```
GET /api/master/empresas/{id}/funcionalidades
→ lista todas as `funcionalidades` com um booleano indicando se está ativa pra essa empresa
POST /api/master/empresas/{id}/funcionalidades/{chave}/ativar
POST /api/master/empresas/{id}/funcionalidades/{chave}/desativar
→ grava em empresa_funcionalidades (liberado_em=now(), liberado_por=<funcionario_id do
token master, se aplicável, senão null>)
```
Ao criar uma empresa vinculada a um plano (Prompt seguinte trata a criação da assinatura), as funcionalidades de `plano_funcionalidades` daquele plano devem ser copiadas automaticamente como "ativas" em `empresa_funcionalidades` — implementar esse copy aqui mesmo, no momento da criação da empresa, SE o body de criação já incluir um `plano_id` opcional.
### 4. Planos
```
GET /api/master/planos
POST /api/master/planos body: {"nome", "valor_mensal_centavos", "descricao", "funcionalidades": ["chave1","chave2"]}
PUT /api/master/planos/{id}
```
Ao criar/editar um plano com a lista de `funcionalidades`, sincronizar `plano_funcionalidades` (remover as que saíram, inserir as novas).
### 5. Logs de envio e clientes pagantes
```
GET /api/master/logs-envio?status=falhou&empresa_id=X (empresa_id opcional)
→ lista de logs_envio_avisos, mais recentes primeiro, paginado (limit/offset)
GET /api/master/empresas?situacao=pagante
→ reaproveita o GET /api/master/empresas, com filtro adicional: join em periodos_uso
do mês corrente, status='pago'
POST /api/master/empresas/{id}/lembrete-cobranca
body: {"canal": "whatsapp"|"email"}
→ grava em lembretes_cobranca; o ENVIO de fato (mensagem real) só é implementado no
Prompt 08, quando a integração Evolution/e-mail existir — por enquanto, só registra
a intenção e retorna sucesso (deixar um TODO claro no código apontando pro Prompt 08)
```
## Critérios de aceite
- [ ] Criar uma empresa via `POST /api/master/empresas` de fato cria o database `db_empresa_<slug>` (reaproveitando o Prompt 03) e um primeiro funcionário consegue logar em seguida via `POST /api/auth/login`.
- [ ] Criar empresa com slug já existente retorna erro 409, sem tentar criar database duplicado.
- [ ] Ativar/desativar funcionalidade reflete corretamente em `GET /api/master/empresas/{id}/funcionalidades`.
- [ ] Criar um plano com 3 funcionalidades e depois criar uma empresa apontando pra esse plano faz as 3 aparecerem já ativas em `empresa_funcionalidades`.
- [ ] `GET /api/master/logs-envio?status=falhou` funciona mesmo sem nenhum log ainda (retorna lista vazia, não erro).
- [ ] Todos os endpoints deste prompt retornam 401 se chamados sem token master válido.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 05 concluído, data, resumo. Confirmar Prompts 01–04 seguem marcados.
+93
View File
@@ -0,0 +1,93 @@
# Prompt 06 — Painel Cliente (escritório): condomínios, contatos, espaços comuns, assembleias, documentos
## Contexto
Prompts 01–05 concluídos: autenticação completa, painel master funcionando, empresas sendo provisionadas com tenant próprio. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa o CRUD principal do menu **Cliente → Cadastro Condomínio**, que é o coração do sistema: condomínios, contatos (síndico/subsíndico/conselheiro), vínculo de funcionários, espaços comuns, assembleias e documentos.
Todos os endpoints deste prompt exigem `exigirFuncionario` (Prompt 04) e operam sobre o `db_empresa_<slug>` do `empresa_id` do token (usar `TenantDataSourceProvider` do Prompt 03).
## Objetivo
CRUD completo de `condominios`, `contatos` + `contato_condominio`, `funcionario_condominio`, `espacos_comuns`, `assembleias`, `documentos` (upload de arquivo).
## Especificação
### 1. Condomínios
```
POST /api/condominios body: {"nome", "cnpj", "endereco"}
GET /api/condominios lista todos do tenant
GET /api/condominios/{id}
→ resposta agregada: dados do condomínio + síndico atual (via ContatoCondominioDao) +
contagem de assembleias futuras + contagem de avisos ativos (isso é o "checklist"
que o funcionário usa como referência rápida)
PUT /api/condominios/{id}
```
### 2. Contatos (síndico/subsíndico/conselheiro)
```
POST /api/contatos body: {"nome", "telefone", "email"}
→ cria a pessoa (sem vínculo ainda)
POST /api/condominios/{id}/contatos/vincular
body: {"contato_id" (ou dados de um contato novo pra criar+vincular numa chamada só),
"tipo": "sindico"|"subsindico"|"conselheiro",
"data_inicio_mandato", "data_fim_mandato"}
→ grava em contato_condominio; se marcar tipo=sindico e já existir outro síndico ativo
pro mesmo condomínio, perguntar/forçar: por padrão, encerrar automaticamente o mandato
anterior (status='inativo', data_fim_mandato=hoje) e ativar o novo — deixar isso
documentado no código como decisão de negócio
GET /api/condominios/{id}/contatos lista todos os vínculos ativos daquele condomínio
DELETE /api/condominios/{id}/contatos/{contatoId}?tipo=... (desvincula — não apaga o contato,
só remove o vínculo daquele condomínio/tipo específico; um contato pode continuar
vinculado a outros condomínios)
```
### 3. Funcionários vinculados a condomínios
```
POST /api/condominios/{id}/funcionarios-vinculados
body: {"funcionario_id"} (funcionario precisa existir em db_master, mesma empresa)
GET /api/condominios/{id}/funcionarios-vinculados
DELETE /api/condominios/{id}/funcionarios-vinculados/{funcionarioId}
```
### 4. Espaços comuns
```
POST /api/condominios/{id}/espacos-comuns
body: {"nome", "descricao", "valor_utilizacao_centavos", "indice_correcao"}
GET /api/condominios/{id}/espacos-comuns
PUT /api/condominios/{id}/espacos-comuns/{espacoId}
→ se o valor mudar, atualizar data_ultimo_reajuste=hoje automaticamente
```
### 5. Assembleias
```
POST /api/condominios/{id}/assembleias
body: {"tipo", "data_hora", "local", "pauta"}
GET /api/condominios/{id}/assembleias (ordenar por data_hora desc)
PUT /api/condominios/{id}/assembleias/{id} (inclui mudar status: agendada/realizada/cancelada)
```
### 6. Documentos (upload)
```
POST /api/condominios/{id}/documentos multipart/form-data: arquivo + tipo
→ salvar o arquivo em um diretório local montado como volume Docker (ex: /app/uploads/
<empresa_slug>/<condominio_id>/<uuid>-<nome_original>), gravar caminho_armazenamento
com esse path relativo
→ validar extensão (permitir: pdf, jpg, jpeg, png, docx) e tamanho máximo (ex: 10MB)
GET /api/condominios/{id}/documentos
GET /api/documentos/{id}/download → serve o arquivo (Content-Disposition: attachment)
```
Adicionar o volume de uploads no `docker-compose.yml` do Prompt 01 (`./uploads:/app/uploads`).
### 7. Auditoria
Toda operação de `POST`/`PUT`/`DELETE` deste prompt deve gravar uma linha em `log_auditoria` (tabela_afetada, registro_id, ação, dados_anteriores/dados_novos em JSON, funcionario_id do token).
## Critérios de aceite
- [ ] Criar um condomínio, vincular um síndico e um subsíndico, e chamar `GET /api/condominios/{id}` mostra o síndico atual corretamente.
- [ ] Vincular um segundo síndico ao mesmo condomínio encerra automaticamente o mandato do primeiro (`status='inativo'`).
- [ ] O mesmo `contato` pode ser vinculado como síndico em dois condomínios diferentes simultaneamente.
- [ ] Upload de um PDF funciona; upload de um `.exe` é rejeitado com erro claro; upload de 15MB é rejeitado.
- [ ] `log_auditoria` recebe uma linha para cada operação de escrita feita neste prompt (testar com pelo menos 3 operações diferentes).
- [ ] Todos os endpoints só retornam/alteram dados do `empresa_id` do token — tentar acessar `/api/condominios/{id}` de outra empresa retorna 404 (não 403 — não revelar que o ID existe em outro tenant).
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 06 concluído, data, resumo. Confirmar Prompts 01–05 seguem marcados.
+63
View File
@@ -0,0 +1,63 @@
# Prompt 07 — Financeiro: assinaturas, período de uso e AbacatePay
## Contexto
Prompts 01–06 concluídos: CRUD completo do escritório funcionando. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa o menu **Cliente → Financeiro** (pagar mensalidade) e a automação de bloqueio/liberação de acesso conforme pagamento, usando AbacatePay para gerar cobrança PIX.
## Objetivo
Criar/consultar `periodos_uso` do mês corrente, gerar cobrança PIX via AbacatePay, processar o webhook de confirmação, e rodar o job diário que bloqueia empresas inadimplentes.
## Especificação
### 1. Cliente AbacatePay
Consultar a documentação oficial da AbacatePay (https://docs.abacatepay.com) para confirmar o formato exato do endpoint de criação de cobrança PIX e do payload do webhook — **não assumir o formato sem checar**, a API pode ter mudado. Implementar `AbacatePayClient`:
- `criarCobranca(valorCentavos, descricao, identificadorExterno)` → retorna `{chargeId, qrCodePayload, qrCodeBase64}`
- Validação de assinatura do webhook, se a AbacatePay oferecer esse mecanismo (verificar na doc).
### 2. Endpoints do escritório
```
GET /api/financeiro/periodo-atual
→ busca (ou cria, se não existir) o periodos_uso do mes_referencia = mês corrente;
retorna status, valor da assinatura vigente, se já tem pagamento pendente/gerado
POST /api/financeiro/pagamentos
→ se já existe um pagamento 'pendente' pro período atual, retorna ele (não duplica
cobrança); senão, chama AbacatePayClient.criarCobranca com o valor da assinatura,
grava em `pagamentos`, retorna qr_code_base64 + qr_code_payload pro frontend renderizar
GET /api/financeiro/pagamentos/{id}
→ status atual (útil pro frontend fazer polling enquanto o QR Code está na tela)
```
### 3. Webhook
```
POST /api/master/webhooks/abacatepay
→ SEM autenticação de master/funcionário (é a AbacatePay chamando) — validar por
assinatura/segredo do webhook, conforme a documentação da AbacatePay
→ identifica o pagamento pelo abacatepay_charge_id, marca status='pago', pago_em=now()
→ marca periodos_uso.status='pago', data_liberacao=now()
→ marca empresas.status='ativa' (caso estivesse 'bloqueada')
→ idempotente: se o webhook chegar duplicado (mesma cobrança já processada), não fazer
nada de errado, só retornar 200 de novo
```
### 4. Job de bloqueio diário
`BloqueioJob`, rodando uma vez por dia (implementar com um `ScheduledExecutorService` simples dentro da própria aplicação Java — não precisa de cron externo nesta fase):
1. Para cada `empresa` com status='ativa': busca (ou cria) o `periodos_uso` do mês corrente.
2. Se `hoje > dia_vencimento` da assinatura E `periodos_uso.status != 'pago'`: marca `periodos_uso.status='bloqueado'`, `data_bloqueio=now()`, `empresas.status='bloqueada'`.
3. Logar quantas empresas foram bloqueadas na execução.
### 5. Bloqueio de acesso
No middleware `exigirFuncionario` (Prompt 04), adicionar a checagem: se `empresas.status='bloqueada'`, retornar 403 com corpo `{"erro": "pendencia_financeira", "mensagem": "..."}` em vez de deixar passar — mas **sempre permitir** o próprio `GET /api/financeiro/periodo-atual` e `POST /api/financeiro/pagamentos` mesmo bloqueado, senão o escritório não consegue nem pagar pra desbloquear.
## Critérios de aceite
- [ ] `POST /api/financeiro/pagamentos` gera de fato uma cobrança na AbacatePay (testar em ambiente sandbox, se a AbacatePay oferecer) e retorna QR Code válido.
- [ ] Chamar `POST /api/financeiro/pagamentos` duas vezes seguidas não cria duas cobranças, retorna a mesma pendente.
- [ ] Simular o webhook (chamada manual com um payload de teste) marca o período como pago e desbloqueia a empresa.
- [ ] Rodar o webhook duas vezes com o mesmo `charge_id` não causa erro nem duplica nada.
- [ ] `BloqueioJob` rodado manualmente (chamar o método direto, sem esperar o schedule) bloqueia corretamente uma empresa de teste com vencimento no passado e sem pagamento.
- [ ] Empresa bloqueada consegue logar e acessar só `/api/financeiro/**`; qualquer outro endpoint retorna 403 com o erro `pendencia_financeira`.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 07 concluído, data, resumo. Confirmar Prompts 01–06 seguem marcados.
+87
View File
@@ -0,0 +1,87 @@
# Prompt 08 — Avisos/alertas: Evolution API (WhatsApp), e-mail e destinatários
## Contexto
Prompts 01–07 concluídos: financeiro funcionando, CRUD de condomínios/contatos completo. **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa: conexão da instância WhatsApp (Evolution API) pelo escritório + aprovação pelo master, configuração de SMTP único da plataforma, e o fluxo completo de criação/envio de avisos com documentos anexados e destinatários.
## Objetivo
CRUD de `avisos` + `aviso_documentos` + `aviso_destinatarios`, conexão/aprovação de instância Evolution, configuração de SMTP, envio real de mensagens (WhatsApp e e-mail) respeitando o catálogo de `funcionalidades` liberado por empresa, e gravação em `logs_envio_avisos`.
## Especificação
### 1. Conectar WhatsApp (escritório)
```
POST /api/config/evolution-instancia/conectar
body: {"instance_url", "numero_whatsapp"}
→ cria a instância na Evolution API (consultar a documentação oficial da Evolution API
pra confirmar o endpoint exato de criação de instância e pareamento por QR Code —
não assumir o formato sem checar), grava em evolution_instancias com status='pendente'
(mesmo depois de parear o QR Code com sucesso do lado da Evolution — a ativação pra
USO no sistema depende da aprovação do master)
→ retorna o QR Code (imagem/base64) pro frontend exibir e o funcionário escanear
GET /api/config/evolution-instancia/status
→ status atual: pendente, aprovada, rejeitada, inativa
```
### 2. Aprovação (master)
```
GET /api/master/empresas/{id}/evolution-instancias
POST /api/master/empresas/{id}/evolution-instancias/{instId}/aprovar
POST /api/master/empresas/{id}/evolution-instancias/{instId}/rejeitar
```
### 3. Configuração de e-mail (master, plataforma única)
```
GET /api/master/configuracoes
PUT /api/master/configuracoes
body: {"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_remetente"}
→ salva em configuracoes_plataforma (senha deve ser guardada de forma reversível pra
uso no envio, mas nunca retornada em texto puro no GET — mascarar tipo "••••1234"
se quiser mostrar algo, ou simplesmente omitir o campo senha no GET)
```
### 4. Avisos — CRUD completo
```
POST /api/condominios/{id}/avisos
body: {"assembleia_id" (opcional), "tipo": "aviso"|"alerta", "titulo", "mensagem",
"data_evento" (opcional), "data_expiracao" (opcional), "prioridade",
"documentos": [documentoId, ...], "destinatarios": [{"tipo":"contato","id":X}, ...],
"enviar_whatsapp": bool, "enviar_email": bool}
→ valida que enviar_whatsapp só pode ser true se a empresa tiver a funcionalidade
'whatsapp_envio' ativa E uma evolution_instancia com status='aprovada'; valida que
enviar_email só pode ser true se a empresa tiver 'email_envio' ativa
→ grava o aviso + vínculos em aviso_documentos + aviso_destinatarios
→ se enviar_whatsapp ou enviar_email = true, dispara o envio (ver seção 5) de forma
síncrona nesta fase (fila assíncrona fica como melhoria futura documentada no código)
GET /api/condominios/{id}/avisos
GET /api/condominios/{id}/avisos/{avisoId}
```
### 5. Envio efetivo
`NotificacaoService.enviarAviso(aviso)`:
1. Busca os `aviso_destinatarios`; para cada um, resolve nome/telefone/email:
- se `tipo_destinatario='contato'`: busca em `contatos` (tenant)
- se `tipo_destinatario='funcionario'`: busca em `db_master.funcionarios`
2. Para cada destinatário, se `enviar_whatsapp`: chama `EvolutionApiClient.enviarMensagem(instanceUrl, token, numero, mensagem, anexos)` (consultar doc da Evolution API pro formato exato de envio com anexo).
3. Se `enviar_email`: chama `EmailClient.enviar(smtpConfig, destinatarioEmail, assunto=titulo, corpo=mensagem, anexos=documentos)` — usar `jakarta.mail` (única dependência nova aqui, é a forma padrão de mandar e-mail em Java puro).
4. Para cada tentativa (sucesso ou falha), grava uma linha em `logs_envio_avisos` (no `db_master`, incluindo `contato_nome` como snapshot).
5. Atualiza `avisos.status_envio` = 'enviado' se todas as tentativas relevantes deram certo, 'falhou' se pelo menos uma falhou.
### 6. Reenvio manual
```
POST /api/condominios/{id}/avisos/{avisoId}/reenviar
→ reprocessa só os destinatários cujo último log foi 'falhou'
```
## Critérios de aceite
- [ ] Escritório sem a funcionalidade `whatsapp_envio` ativa recebe erro claro ao tentar criar aviso com `enviar_whatsapp=true`.
- [ ] Conectar uma instância Evolution gera QR Code; aviso criado com `enviar_whatsapp=true` antes da aprovação do master é rejeitado com mensagem clara ("instância pendente de aprovação").
- [ ] Depois do master aprovar a instância, o mesmo aviso consegue ser enviado.
- [ ] Envio por e-mail funciona de ponta a ponta usando um SMTP de teste (ex: Mailtrap ou Gmail com senha de app) configurado via `PUT /api/master/configuracoes`.
- [ ] Um destinatário com telefone/e-mail inválido gera uma linha `falhou` em `logs_envio_avisos` sem quebrar o envio pros demais destinatários.
- [ ] `POST .../reenviar` só reprocessa quem falhou, não reenvia pra quem já recebeu com sucesso.
- [ ] Documento anexado ao aviso (via `aviso_documentos`) é de fato enviado como anexo no WhatsApp/e-mail.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 08 concluído, data, resumo. Confirmar Prompts 01–07 seguem marcados.
+75
View File
@@ -0,0 +1,75 @@
# Prompt 09 — Frontend base: layout, menus, autenticação e telas principais
## Contexto
Prompts 01–08 concluídos: toda a API funcionando (master, escritório, financeiro, avisos). **Leia `10-tracker-progresso.md` antes de começar.**
Este prompt implementa o frontend estático (HTML/CSS/JS puro, sem build step) descrito na arquitetura v3.0 — servido pelo mesmo `HttpServer` Java via `StaticFileHandler` (Prompt 01).
## Objetivo
Layout base com menu lateral (Master e Cliente), tela de login única (detecta se é master ou funcionário pelo formulário escolhido), e as telas principais consumindo a API via `fetch()`.
## Especificação
### 1. Estrutura de arquivos
```
src/main/webapp/
├── index.html → redireciona pra /login.html se não autenticado
├── login.html → formulário único com abas "Master" / "Escritório"
├── css/
│ └── app.css (layout base: sidebar + conteúdo, sem framework CSS)
├── js/
│ ├── api.js (wrapper de fetch: injeta Authorization: Bearer <token do localStorage>,
│ trata 401 redirecionando pro login, trata 403 pendencia_financeira
│ mostrando um banner de aviso)
│ ├── auth.js (login, logout, leitura do token)
│ └── util.js (formatação de data, moeda em centavos → R$, toasts de sucesso/erro)
├── master/
│ ├── clientes.html + clientes.js
│ ├── planos.html + planos.js
│ ├── financeiro.html + financeiro.js
│ └── configuracoes.html + configuracoes.js
└── cliente/
├── condominios.html + condominios.js (lista + detalhe agregado do Prompt 06)
├── condominio-detalhe.html + condominio-detalhe.js
│ (abas: Contatos / Espaços comuns / Assembleias / Documentos / Avisos)
├── financeiro.html + financeiro.js (mostra QR Code do PIX, faz polling de status)
└── configuracoes.html + configuracoes.js (conectar WhatsApp, mostra QR Code da Evolution)
```
### 2. Layout base (menu lateral)
Reproduzir exatamente a estrutura definida no documento de arquitetura (seção "Dashboard"):
```
MASTER: Clientes | Planos | Financeiro | Configurações
CLIENTE: Cadastro Condomínio | Financeiro | Configurações
```
- Sidebar fixa à esquerda, com o nome da empresa/usuário logado no topo e botão de logout.
- Indicar visualmente o item de menu ativo.
- Responsivo o suficiente pra não quebrar em uma tela de notebook comum (não precisa ser mobile-first).
### 3. Autenticação no frontend
- `login.html`: duas abas. Aba "Master" só pede senha, chama `POST /api/master/login`. Aba "Escritório" pede e-mail+senha, chama `POST /api/auth/login`.
- Token salvo em `localStorage` (`master_token` ou `funcionario_token`, com o escopo junto pra saber pra qual sidebar redirecionar).
- Todas as páginas verificam token válido no carregamento; se ausente/expirado, redireciona pro login.
### 4. Telas — nível de detalhe esperado
Cada tela deve, no mínimo:
- Listar os dados via `GET`, em tabela simples.
- Ter um formulário (modal ou seção inline) para criar/editar, chamando `POST`/`PUT`.
- Mostrar mensagens de erro da API de forma legível (não só "erro 500").
- `condominio-detalhe.html`: a tela mais rica — mostra os dados do condomínio, e nas abas permite cadastrar contato (com seletor de tipo sindico/subsindico/conselheiro), listar/cadastrar espaços comuns, listar/cadastrar assembleias, fazer upload de documento (`<input type="file">`), e criar um aviso selecionando destinatários (checkboxes dos contatos + funcionários vinculados) e documentos já cadastrados pra anexar.
- `cliente/financeiro.html`: mostra status do período atual; se pendente, botão "Gerar cobrança PIX" que chama a API e renderiza o QR Code (`qr_code_base64` como `<img src="data:image/png;base64,...">`) + o código copia-e-cola em um campo com botão "Copiar"; faz polling a cada 5s em `GET /api/financeiro/pagamentos/{id}` até status mudar pra 'pago'.
- `cliente/configuracoes.html`: botão "Conectar WhatsApp" que chama a API e mostra o QR Code da Evolution pra escanear; mostra o status atual (pendente/aprovada/rejeitada).
### 5. Erro de pendência financeira
Se qualquer chamada de API (fora `/api/financeiro/**`) retornar 403 com `erro: "pendencia_financeira"`, `api.js` deve interceptar e mostrar um banner fixo no topo: "Seu acesso está bloqueado por pendência de pagamento — regularize em Financeiro." com link direto pra `cliente/financeiro.html`, sem deixar a tela quebrada.
## Critérios de aceite
- [ ] Login como master e como funcionário funcionam, cada um caindo na sidebar correta.
- [ ] Fluxo completo pela UI: criar condomínio → cadastrar síndico → cadastrar espaço comum → criar assembleia → subir documento → criar aviso vinculando o documento e selecionando o síndico como destinatário.
- [ ] Tela de financeiro do escritório gera e exibe QR Code real (integrado com o Prompt 07).
- [ ] Tela de configurações do escritório exibe QR Code da Evolution (integrado com o Prompt 08).
- [ ] Banner de pendência financeira aparece corretamente quando a empresa está bloqueada, sem quebrar a navegação.
- [ ] Nenhuma tela depende de build step (webpack/vite/npm) — abre direto servido pelo `HttpServer` Java.
## Ao concluir
Atualizar `10-tracker-progresso.md`: Prompt 09 concluído, data, resumo. Confirmar Prompts 01–08 seguem marcados.
+37
View File
@@ -0,0 +1,37 @@
# Tracker de Progresso — Sistema de Contabilidade de Condomínios
> **Regra obrigatória para a IA executora:** antes de iniciar qualquer prompt, leia este arquivo inteiro e confirme que o prompt anterior está marcado como ✅ Concluído. Nunca avance para o próximo prompt com uma fase anterior incompleta ou com critérios de aceite não verificados. Ao terminar um prompt, atualize a linha correspondente aqui (status, data, resumo) antes de considerar a fase encerrada.
| # | Prompt | Status | Data | Resumo |
|---|---|---|---|---|
| 01 | Setup projeto, Docker, HttpServer básico | ✅ Concluído | 2026-09-28 | Projeto Java/Maven criado com Docker, PostgreSQL, HttpServer, healthcheck, arquivos estáticos e MigrationRunner no-op validados. |
| 02 | Migrations `db_master` | ✅ Concluído | 2026-09-28 | Criadas 15 migrations master com seed de 30 funcionalidades e DAOs JDBC básicos validados. |
| 03 | Migrations `db_empresa` (tenant) + provisionamento | ✅ Concluído | 2026-09-28 | Criadas migrations tenant, TenantProvisioner, cache por empresa e DAOs básicos com smoke test validado. |
| 04 | Autenticação: master, JWT, API Key | ✅ Concluído | 2026-09-28 | Implementados BCrypt, JWT HMAC-SHA256, API Key, logins e middlewares com testes de aceite. |
| 05 | Painel Master: clientes e planos | ✅ Concluído | 2026-09-28 | Implementados endpoints master de empresas, funcionários, planos, funcionalidades, logs e lembretes com validação HTTP. |
| 06 | Painel Cliente: condomínios, contatos, espaços, assembleias, documentos | ✅ Concluído | 2026-09-28 | Implementados endpoints cliente para condomínios, contatos, funcionários vinculados, espaços, assembleias, documentos/upload e auditoria. |
| 07 | Financeiro: assinaturas, período de uso, AbacatePay | ✅ Concluído | 2026-09-28 | Implementados período atual, cobrança PIX AbacatePay/mock, webhook idempotente, job de bloqueio e acesso financeiro para inadimplentes. |
| 08 | Avisos/alertas: Evolution API, e-mail, destinatários | ✅ Concluído | 2026-09-28 | Implementados conexão/aprovação Evolution, SMTP master, CRUD/envio de avisos, logs e reenvio de falhas. |
| 09 | Frontend base: layout, menus, telas principais | 🔶 Em andamento | 2026-09-28 | Frontend estático base implementado com login, layout, telas master/cliente, financeiro PIX, WhatsApp e detalhe de condomínio; build Maven validado, pendente teste E2E HTTP. |
Status possíveis: `⬜ Pendente` · `🔶 Em andamento` · `✅ Concluído` · `❌ Bloqueado` (se bloqueado, escrever o motivo no resumo).
## Decisões de arquitetura já fechadas (não reabrir sem necessidade real)
- Java puro + JDBC, sem framework; `com.sun.net.httpserver.HttpServer` para HTTP.
- PostgreSQL 16, 1 servidor, 1 database por empresa (`db_empresa_<slug>`).
- Frontend HTML/CSS/JS puro, servido pelo mesmo processo (sem build step).
- Autenticação em 3 camadas: senha master → JWT master; funcionário → JWT empresa; app externo → API Key.
- Empresas cadastradas só pelo master (sem autoatendimento).
- Liberação de uso mensal automática via webhook AbacatePay; bloqueio automático por job diário se vencido sem pagamento (dados nunca apagados).
- Evolution API: cada escritório conecta sua própria instância; ativação depende de aprovação do master.
- E-mail: uma única conta SMTP da plataforma, configurada pelo master.
- **Sem** moradores/unidades, reservas de área comum, prestação de contas/financeiro do condomínio, ocorrências, fornecedores ou votações — sistema é um cadastro de referência, não um ERP de condomínio.
- Contatos (síndico/subsíndico/conselheiro) são entidades desacopladas dos condomínios (N:N via `contato_condominio`) — a mesma pessoa pode atender mais de um condomínio.
- Funcionários do escritório podem ser vinculados a condomínios específicos e também recebem avisos/alertas.
- Assembleias são uma entidade própria; avisos podem opcionalmente referenciar uma assembleia e ter documentos anexados.
## Notas / pendências conhecidas (não bloqueiam o MVP, mas ficam registradas)
- Provisionamento de tenant (Prompt 05) roda de forma síncrona na criação da empresa — migrar para fila assíncrona é uma melhoria futura.
- Envio de aviso (Prompt 08) roda de forma síncrona — idem, fila assíncrona é melhoria futura.
- Cache de conexão por tenant (Prompt 03) não tem invalidação automática quando uma empresa é bloqueada/cancelada — só um método pronto, não chamado ainda.
- Funcionalidades "Fase 2" do catálogo (templates, avisos recorrentes, webhooks, busca avançada) estão seedadas no banco mas sem handler implementado.
+124
View File
@@ -0,0 +1,124 @@
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:
- app
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: db_master
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d db_master"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
evolution-api:
image: evoapicloud/evolution-api:v2.3.7
restart: unless-stopped
environment:
SERVER_URL: ${EVOLUTION_PUBLIC_URL:-https://condominio.seconectai.com.br/evolution}
AUTHENTICATION_API_KEY: ${EVOLUTION_GLOBAL_API_KEY}
DATABASE_ENABLED: "true"
DATABASE_PROVIDER: postgresql
DATABASE_CONNECTION_URI: ${EVOLUTION_DATABASE_CONNECTION_URI:-postgresql://app:${DB_PASSWORD}@postgres:5432/evolution}
DATABASE_CONNECTION_CLIENT_NAME: seconectai_evolution
DATABASE_SAVE_DATA_INSTANCE: "true"
DATABASE_SAVE_DATA_NEW_MESSAGE: "true"
DATABASE_SAVE_MESSAGE_UPDATE: "true"
DATABASE_SAVE_DATA_CONTACTS: "true"
DATABASE_SAVE_DATA_CHATS: "true"
DATABASE_SAVE_DATA_LABELS: "true"
DATABASE_SAVE_DATA_HISTORIC: "true"
CACHE_REDIS_ENABLED: "true"
CACHE_REDIS_URI: redis://redis:6379/1
CACHE_REDIS_PREFIX_KEY: seconectai_evolution
CACHE_REDIS_SAVE_INSTANCES: "false"
CACHE_LOCAL_ENABLED: "false"
DEL_INSTANCE: "false"
volumes:
- evolution_instances:/evolution/instances
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
app:
build: .
restart: unless-stopped
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD}
DB_MASTER_NAME: db_master
HTTP_PORT: 8080
MASTER_PASSWORD_HASH: ${MASTER_PASSWORD_HASH}
JWT_SECRET: ${JWT_SECRET}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-https://condominio.seconectai.com.br}
ABACATEPAY_WEBHOOK_SECRET: ${ABACATEPAY_WEBHOOK_SECRET}
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
depends_on:
postgres:
condition: service_healthy
evolution-api:
condition: service_started
volumes:
- ./uploads:/app/uploads
mensageiro:
build: .
restart: unless-stopped
environment:
APP_MODE: worker
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD}
DB_MASTER_NAME: db_master
HTTP_PORT: 8080
MASTER_PASSWORD_HASH: ${MASTER_PASSWORD_HASH}
JWT_SECRET: ${JWT_SECRET}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-https://condominio.seconectai.com.br}
ABACATEPAY_WEBHOOK_SECRET: ${ABACATEPAY_WEBHOOK_SECRET}
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
depends_on:
postgres:
condition: service_healthy
evolution-api:
condition: service_started
volumes:
- ./uploads:/app/uploads
volumes:
pgdata:
redisdata:
evolution_instances:
caddy_data:
caddy_config:
+98
View File
@@ -0,0 +1,98 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: db_master
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
evolution-api:
image: evoapicloud/evolution-api:v2.3.7
environment:
SERVER_URL: ${EVOLUTION_PUBLIC_URL:-http://localhost:8081}
AUTHENTICATION_API_KEY: ${EVOLUTION_GLOBAL_API_KEY}
DATABASE_ENABLED: "true"
DATABASE_PROVIDER: postgresql
DATABASE_CONNECTION_URI: ${EVOLUTION_DATABASE_CONNECTION_URI:-postgresql://app:${DB_PASSWORD}@postgres:5432/evolution}
DATABASE_CONNECTION_CLIENT_NAME: seconectai_evolution
DATABASE_SAVE_DATA_INSTANCE: "true"
DATABASE_SAVE_DATA_NEW_MESSAGE: "true"
DATABASE_SAVE_MESSAGE_UPDATE: "true"
DATABASE_SAVE_DATA_CONTACTS: "true"
DATABASE_SAVE_DATA_CHATS: "true"
DATABASE_SAVE_DATA_LABELS: "true"
DATABASE_SAVE_DATA_HISTORIC: "true"
CACHE_REDIS_ENABLED: "true"
CACHE_REDIS_URI: redis://redis:6379/1
CACHE_REDIS_PREFIX_KEY: seconectai_evolution
CACHE_REDIS_SAVE_INSTANCES: "false"
CACHE_LOCAL_ENABLED: "false"
DEL_INSTANCE: "false"
ports:
- "8081:8080"
volumes:
- evolution_instances:/evolution/instances
depends_on:
- postgres
- redis
app:
build: .
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD}
DB_MASTER_NAME: db_master
HTTP_PORT: 8080
MASTER_PASSWORD_HASH: ${MASTER_PASSWORD_HASH}
JWT_SECRET: ${JWT_SECRET}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:8080}
ABACATEPAY_WEBHOOK_SECRET: ${ABACATEPAY_WEBHOOK_SECRET:-dev_webhook_secret}
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
depends_on:
- postgres
- evolution-api
ports:
- "8080:8080"
volumes:
- ./uploads:/app/uploads
mensageiro:
build: .
environment:
APP_MODE: worker
DB_HOST: postgres
DB_PORT: 5432
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD}
DB_MASTER_NAME: db_master
HTTP_PORT: 8080
MASTER_PASSWORD_HASH: ${MASTER_PASSWORD_HASH}
JWT_SECRET: ${JWT_SECRET}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:8080}
ABACATEPAY_WEBHOOK_SECRET: ${ABACATEPAY_WEBHOOK_SECRET:-dev_webhook_secret}
ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-}
EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-}
EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080}
depends_on:
- postgres
- evolution-api
volumes:
- ./uploads:/app/uploads
volumes:
pgdata:
evolution_instances:
@@ -0,0 +1,2 @@
SELECT 'CREATE DATABASE evolution'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'evolution')\gexec
+95
View File
@@ -0,0 +1,95 @@
<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>
<groupId>com.seconectai</groupId>
<artifactId>condocontab</artifactId>
<version>0.1.0</version>
<name>SeConectai Condominios</name>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.4</version>
</dependency>
<dependency>
<groupId>at.favre.lib</groupId>
<artifactId>bcrypt</artifactId>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>src/main/webapp</directory>
<targetPath>webapp</targetPath>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.seconectai.condocontab.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,111 @@
package com.seconectai.condocontab;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.http.AuthHandlers;
import com.seconectai.condocontab.auth.http.AuthMiddleware;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.db.MigrationRunner;
import com.seconectai.condocontab.financeiro.AbacatePayClient;
import com.seconectai.condocontab.financeiro.AbacatePayWebhookHandler;
import com.seconectai.condocontab.financeiro.BloqueioJob;
import com.seconectai.condocontab.financeiro.FinanceiroHandlers;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.http.Router;
import com.seconectai.condocontab.http.StaticFileHandler;
import com.seconectai.condocontab.integracao.EmailClient;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.master.http.MasterHandlers;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.tenant.http.ClienteHandlers;
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.file.Path;
import java.sql.Connection;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public final class Main {
private Main() {
}
public static void main(String[] args) {
try {
AppConfig config = AppConfig.fromEnv();
DataSourceProvider dataSourceProvider = new DataSourceProvider(config);
dataSourceProvider.aguardarConexaoMaster(30, 1000);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
MigrationRunner.runMigrations(conn, "migrations/master");
}
JwtService jwtService = new JwtService(config.jwtSecret());
ApiKeyService apiKeyService = new ApiKeyService(dataSourceProvider);
AbacatePayClient abacatePayClient = new AbacatePayClient(config, dataSourceProvider);
EvolutionApiClient evolutionApiClient = new EvolutionApiClient(config);
EmailClient emailClient = new EmailClient();
AuthHandlers authHandlers = new AuthHandlers(config, dataSourceProvider, jwtService);
AuthMiddleware authMiddleware = new AuthMiddleware(jwtService, apiKeyService);
TenantDataSourceProvider tenantDataSourceProvider = new TenantDataSourceProvider(config, dataSourceProvider);
MasterHandlers masterHandlers = new MasterHandlers(dataSourceProvider, new TenantProvisioner(config), evolutionApiClient);
ClienteHandlers clienteHandlers = new ClienteHandlers(tenantDataSourceProvider, dataSourceProvider, Path.of("/app/uploads"), evolutionApiClient, emailClient);
FinanceiroHandlers financeiroHandlers = new FinanceiroHandlers(dataSourceProvider, abacatePayClient);
AbacatePayWebhookHandler abacatePayWebhookHandler = new AbacatePayWebhookHandler(dataSourceProvider, abacatePayClient);
if ("worker".equalsIgnoreCase(System.getenv().getOrDefault("APP_MODE", "web"))) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
try {
int processados = clienteHandlers.processarFilaEnvios();
if (processados > 0) System.out.println("Mensageiro processou " + processados + " mensagens");
} catch (Exception e) {
e.printStackTrace(System.err);
}
}, 2, 5, TimeUnit.SECONDS);
System.out.println("Mensageiro de envios iniciado");
new CountDownLatch(1).await();
return;
}
Router router = new Router();
router.get("/health", Main::health);
router.post("/api/master/login", authHandlers.masterLogin());
router.post("/api/auth/login", authHandlers.funcionarioLogin());
router.post("/api/master/webhooks/abacatepay", abacatePayWebhookHandler);
router.prefix("/api/master/", authMiddleware.exigirMaster(masterHandlers));
router.prefix("/api/financeiro/", authMiddleware.exigirFuncionario(financeiroHandlers));
router.prefix("/api/", authMiddleware.exigirFuncionario(clienteHandlers));
router.fallback(new StaticFileHandler());
HttpServer server = HttpServer.create(new InetSocketAddress(config.httpPort()), 0);
server.createContext("/", router);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(new BloqueioJob(dataSourceProvider), 1, 24 * 60 * 60, TimeUnit.SECONDS);
System.out.println("Servidor HTTP iniciado na porta " + config.httpPort());
} catch (Exception e) {
System.err.println("Falha ao iniciar aplicacao: " + e.getMessage());
e.printStackTrace(System.err);
System.exit(1);
}
}
private static void health(HttpExchange exchange) throws java.io.IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
HttpResponses.json(exchange, 200, Map.of("status", "ok"));
}
private static void apiNaoImplementada(HttpExchange exchange) throws java.io.IOException {
HttpResponses.json(exchange, 404, Map.of("erro", "endpoint_nao_implementado"));
}
}
@@ -0,0 +1,55 @@
package com.seconectai.condocontab.auth;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.OptionalLong;
public final class ApiKeyService {
private final DataSourceProvider dataSourceProvider;
private final SecureRandom secureRandom = new SecureRandom();
public ApiKeyService(DataSourceProvider dataSourceProvider) {
this.dataSourceProvider = dataSourceProvider;
}
public ChaveGerada gerar() {
byte[] random = new byte[32];
secureRandom.nextBytes(random);
String chave = "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
return new ChaveGerada(chave, hash(chave));
}
public OptionalLong validar(String chaveRecebida) throws SQLException {
if (chaveRecebida == null || chaveRecebida.isBlank()) {
return OptionalLong.empty();
}
String keyHash = hash(chaveRecebida);
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("SELECT empresa_id FROM api_keys WHERE key_hash = ? AND ativa = true")) {
ps.setString(1, keyHash);
try (var rs = ps.executeQuery()) {
return rs.next() ? OptionalLong.of(rs.getLong("empresa_id")) : OptionalLong.empty();
}
}
}
public static String hash(String chave) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(chave.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("Falha ao gerar hash de API Key", e);
}
}
public record ChaveGerada(String chave, String hash) {
}
}
@@ -0,0 +1,108 @@
package com.seconectai.condocontab.auth;
import com.seconectai.condocontab.util.Json;
import com.seconectai.condocontab.util.SimpleJson;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public final class JwtService {
private final byte[] secret;
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
private final Base64.Decoder decoder = Base64.getUrlDecoder();
public JwtService(String secret) {
if (secret == null || secret.isBlank()) {
throw new IllegalArgumentException("JWT_SECRET obrigatorio");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
public String gerar(Map<String, Object> claims, long validadeSegundos) {
Map<String, Object> header = Map.of("alg", "HS256", "typ", "JWT");
Map<String, Object> payload = new LinkedHashMap<>(claims);
payload.put("exp", Instant.now().getEpochSecond() + validadeSegundos);
String headerPart = encode(Json.write(header));
String payloadPart = encode(Json.write(payload));
String signingInput = headerPart + "." + payloadPart;
return signingInput + "." + encoder.encodeToString(hmac(signingInput));
}
public Optional<Map<String, Object>> validar(String token) {
try {
if (token == null) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
return Optional.empty();
}
String signingInput = parts[0] + "." + parts[1];
String expected = encoder.encodeToString(hmac(signingInput));
if (!constantTimeEquals(expected, parts[2])) {
return Optional.empty();
}
String payloadJson = new String(decoder.decode(parts[1]), StandardCharsets.UTF_8);
Map<String, String> parsed = SimpleJson.parseObject(payloadJson);
long exp = Long.parseLong(parsed.getOrDefault("exp", "0"));
if (Instant.now().getEpochSecond() >= exp) {
return Optional.empty();
}
Map<String, Object> claims = new LinkedHashMap<>();
parsed.forEach((k, v) -> claims.put(k, parseClaim(v)));
return Optional.of(claims);
} catch (Exception e) {
return Optional.empty();
}
}
private Object parseClaim(String value) {
if (value == null) {
return null;
}
if (value.matches("-?\\d+")) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return value;
}
}
if ("true".equals(value) || "false".equals(value)) {
return Boolean.parseBoolean(value);
}
return value;
}
private String encode(String text) {
return encoder.encodeToString(text.getBytes(StandardCharsets.UTF_8));
}
private byte[] hmac(String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Falha ao assinar JWT", e);
}
}
private boolean constantTimeEquals(String a, String b) {
byte[] left = a.getBytes(StandardCharsets.UTF_8);
byte[] right = b.getBytes(StandardCharsets.UTF_8);
if (left.length != right.length) {
return false;
}
int diff = 0;
for (int i = 0; i < left.length; i++) {
diff |= left[i] ^ right[i];
}
return diff == 0;
}
}
@@ -0,0 +1,19 @@
package com.seconectai.condocontab.auth;
import at.favre.lib.crypto.bcrypt.BCrypt;
public final class PasswordHasher {
private PasswordHasher() {
}
public static String hash(String senha) {
return BCrypt.withDefaults().hashToString(12, senha.toCharArray());
}
public static boolean verificar(String senha, String hash) {
if (senha == null || hash == null || hash.isBlank()) {
return false;
}
return BCrypt.verifyer().verify(senha.toCharArray(), hash.toCharArray()).verified;
}
}
@@ -0,0 +1,39 @@
package com.seconectai.condocontab.auth;
import java.time.Clock;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class RateLimiter {
private final Map<String, Deque<Long>> tentativas = new ConcurrentHashMap<>();
private final int maxTentativas;
private final long janelaMillis;
private final Clock clock;
public RateLimiter(int maxTentativas, long janelaMillis) {
this(maxTentativas, janelaMillis, Clock.systemUTC());
}
RateLimiter(int maxTentativas, long janelaMillis, Clock clock) {
this.maxTentativas = maxTentativas;
this.janelaMillis = janelaMillis;
this.clock = clock;
}
public boolean permitir(String chave) {
long agora = clock.millis();
Deque<Long> fila = tentativas.computeIfAbsent(chave, ignored -> new ArrayDeque<>());
synchronized (fila) {
while (!fila.isEmpty() && agora - fila.peekFirst() > janelaMillis) {
fila.removeFirst();
}
if (fila.size() >= maxTentativas) {
return false;
}
fila.addLast(agora);
return true;
}
}
}
@@ -0,0 +1,117 @@
package com.seconectai.condocontab.auth.http;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.PasswordHasher;
import com.seconectai.condocontab.auth.RateLimiter;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.master.dao.EmpresaDao;
import com.seconectai.condocontab.master.dao.FuncionarioDao;
import com.seconectai.condocontab.master.model.Empresa;
import com.seconectai.condocontab.master.model.Funcionario;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.util.LinkedHashMap;
import java.util.Map;
public final class AuthHandlers {
private static final long MASTER_VALIDADE_SEGUNDOS = 2 * 60 * 60;
private static final long FUNCIONARIO_VALIDADE_SEGUNDOS = 8 * 60 * 60;
private final AppConfig config;
private final DataSourceProvider dataSourceProvider;
private final JwtService jwtService;
private final RateLimiter rateLimiter;
public AuthHandlers(AppConfig config, DataSourceProvider dataSourceProvider, JwtService jwtService) {
this.config = config;
this.dataSourceProvider = dataSourceProvider;
this.jwtService = jwtService;
this.rateLimiter = new RateLimiter(5, 5 * 60 * 1000L);
}
public HttpHandler masterLogin() {
return exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
HttpResponses.methodNotAllowed(exchange);
return;
}
String ip = clientIp(exchange);
if (!rateLimiter.permitir("master:" + ip)) {
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
return;
}
Map<String, String> body = body(exchange);
String senha = body.get("senha");
if (!PasswordHasher.verificar(senha, config.masterPasswordHash())) {
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
return;
}
String token = jwtService.gerar(Map.of("escopo", "master"), MASTER_VALIDADE_SEGUNDOS);
HttpResponses.json(exchange, 200, Map.of("token", token));
};
}
public HttpHandler funcionarioLogin() {
return exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
HttpResponses.methodNotAllowed(exchange);
return;
}
String ip = clientIp(exchange);
if (!rateLimiter.permitir("funcionario:" + ip)) {
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
return;
}
Map<String, String> body = body(exchange);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
Funcionario funcionario = new FuncionarioDao(conn).buscarPorEmail(body.get("email")).orElse(null);
if (funcionario == null || !PasswordHasher.verificar(body.get("senha"), funcionario.senhaHash())) {
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
return;
}
if (!"ativo".equals(funcionario.status())) {
HttpResponses.json(exchange, 403, Map.of("erro", "cadastro_desativado", "mensagem", "Cadastro de funcionario desativado."));
return;
}
Empresa empresa = new EmpresaDao(conn).buscarPorId(funcionario.empresaId()).orElse(null);
if (empresa == null || !("ativa".equals(empresa.status()) || "bloqueada".equals(empresa.status()))) {
HttpResponses.json(exchange, 403, Map.of("erro", "empresa_inativa", "mensagem", "Escritorio nao esta ativo."));
return;
}
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("escopo", "empresa");
claims.put("empresa_id", funcionario.empresaId());
claims.put("funcionario_id", funcionario.id());
claims.put("cargo", funcionario.cargo() == null ? "" : funcionario.cargo());
String token = jwtService.gerar(claims, FUNCIONARIO_VALIDADE_SEGUNDOS);
HttpResponses.json(exchange, 200, Map.of("token", token));
} catch (Exception e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_login"));
}
};
}
private Map<String, String> body(HttpExchange exchange) throws IOException {
String text = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
try {
return SimpleJson.parseObject(text);
} catch (IllegalArgumentException e) {
return Map.of();
}
}
private String clientIp(HttpExchange exchange) {
String forwarded = exchange.getRequestHeaders().getFirst("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,105 @@
package com.seconectai.condocontab.auth.http;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.http.HttpResponses;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Map;
public final class AuthMiddleware {
public static final String ATTR_EMPRESA_ID = "empresa_id";
public static final String ATTR_FUNCIONARIO_ID = "funcionario_id";
public static final String ATTR_CARGO = "cargo";
private final JwtService jwtService;
private final ApiKeyService apiKeyService;
public AuthMiddleware(JwtService jwtService, ApiKeyService apiKeyService) {
this.jwtService = jwtService;
this.apiKeyService = apiKeyService;
}
public HttpHandler exigirMaster(HttpHandler next) {
return exchange -> {
var claims = bearerClaims(exchange);
if (claims == null || !"master".equals(claims.get("escopo"))) {
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
return;
}
next.handle(exchange);
};
}
public HttpHandler exigirFuncionario(HttpHandler next) {
return exchange -> {
var claims = bearerClaims(exchange);
if (claims == null || !"empresa".equals(claims.get("escopo"))) {
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
return;
}
exchange.setAttribute(ATTR_EMPRESA_ID, toLong(claims.get("empresa_id")));
exchange.setAttribute(ATTR_FUNCIONARIO_ID, toLong(claims.get("funcionario_id")));
exchange.setAttribute(ATTR_CARGO, String.valueOf(claims.getOrDefault("cargo", "")));
Long empresaId = toLong(claims.get("empresa_id"));
if (empresaBloqueada(empresaId) && !exchange.getRequestURI().getPath().startsWith("/api/financeiro/")) {
HttpResponses.json(exchange, 403, Map.of("erro", "pendencia_financeira", "mensagem", "Seu acesso esta bloqueado por pendencia de pagamento."));
return;
}
next.handle(exchange);
};
}
public HttpHandler exigirApiKey(HttpHandler next) {
return exchange -> {
String chave = exchange.getRequestHeaders().getFirst("X-API-Key");
try {
var empresaId = apiKeyService.validar(chave);
if (empresaId.isEmpty()) {
HttpResponses.json(exchange, 401, Map.of("erro", "api_key_invalida"));
return;
}
exchange.setAttribute(ATTR_EMPRESA_ID, empresaId.getAsLong());
next.handle(exchange);
} catch (SQLException e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_validacao_api_key"));
}
};
}
private Map<String, Object> bearerClaims(HttpExchange exchange) throws IOException {
String auth = exchange.getRequestHeaders().getFirst("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
return null;
}
return jwtService.validar(auth.substring("Bearer ".length()).trim()).orElse(null);
}
private boolean empresaBloqueada(long empresaId) {
try (var conn = apiKeyServiceMasterConnection();
var ps = conn.prepareStatement("SELECT status FROM empresas WHERE id=?")) {
ps.setLong(1, empresaId);
try (var rs = ps.executeQuery()) { return rs.next() && "bloqueada".equals(rs.getString(1)); }
} catch (Exception e) { return false; }
}
private java.sql.Connection apiKeyServiceMasterConnection() throws java.sql.SQLException {
try {
var f = ApiKeyService.class.getDeclaredField("dataSourceProvider");
f.setAccessible(true);
var ds = (com.seconectai.condocontab.config.DataSourceProvider) f.get(apiKeyService);
return ds.getMasterConnection();
} catch (ReflectiveOperationException e) { throw new java.sql.SQLException(e); }
}
private long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
return Long.parseLong(String.valueOf(value));
}
}
@@ -0,0 +1,112 @@
package com.seconectai.condocontab.config;
import java.util.Map;
public final class AppConfig {
private final String dbHost;
private final int dbPort;
private final String dbUser;
private final String dbPassword;
private final String dbMasterName;
private final int httpPort;
private final String masterPasswordHash;
private final String jwtSecret;
private final String abacatePayApiKey;
private final String abacatePayWebhookSecret;
private final String appPublicUrl;
private final String evolutionGlobalApiKey;
private final String evolutionApiUrl;
private AppConfig(String dbHost, int dbPort, String dbUser, String dbPassword, String dbMasterName,
int httpPort, String masterPasswordHash, String jwtSecret, String abacatePayApiKey,
String abacatePayWebhookSecret, String appPublicUrl, String evolutionGlobalApiKey, String evolutionApiUrl) {
this.dbHost = dbHost;
this.dbPort = dbPort;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
this.dbMasterName = dbMasterName;
this.httpPort = httpPort;
this.masterPasswordHash = masterPasswordHash;
this.jwtSecret = jwtSecret;
this.abacatePayApiKey = abacatePayApiKey;
this.abacatePayWebhookSecret = abacatePayWebhookSecret;
this.appPublicUrl = appPublicUrl;
this.evolutionGlobalApiKey = evolutionGlobalApiKey;
this.evolutionApiUrl = evolutionApiUrl;
}
public static AppConfig fromEnv() {
Map<String, String> env = System.getenv();
return new AppConfig(
value(env, "DB_HOST", "localhost"),
intValue(env, "DB_PORT", 5432),
required(env, "DB_USER"),
required(env, "DB_PASSWORD"),
value(env, "DB_MASTER_NAME", "db_master"),
intValue(env, "HTTP_PORT", 8080),
required(env, "MASTER_PASSWORD_HASH"),
required(env, "JWT_SECRET"),
value(env, "ABACATEPAY_API_KEY", ""),
value(env, "ABACATEPAY_WEBHOOK_SECRET", "dev_webhook_secret"),
value(env, "APP_PUBLIC_URL", "http://localhost:8080"),
value(env, "EVOLUTION_GLOBAL_API_KEY", ""),
value(env, "EVOLUTION_API_URL", "mock://evolution")
);
}
private static String required(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Variavel de ambiente obrigatoria ausente: " + name);
}
return value;
}
private static String value(Map<String, String> env, String name, String defaultValue) {
String value = env.get(name);
return value == null || value.isBlank() ? defaultValue : value;
}
private static int intValue(Map<String, String> env, String name, int defaultValue) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return defaultValue;
}
return Integer.parseInt(value);
}
public String masterJdbcUrl() {
return jdbcUrl(dbMasterName);
}
public String postgresJdbcUrl() {
return jdbcUrl("postgres");
}
public String jdbcUrl(String databaseName) {
return "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + databaseName;
}
public String dbUser() {
return dbUser;
}
public String dbPassword() {
return dbPassword;
}
public int httpPort() {
return httpPort;
}
public String masterPasswordHash() {
return masterPasswordHash;
}
public String jwtSecret() { return jwtSecret; }
public String abacatePayApiKey() { return abacatePayApiKey; }
public String abacatePayWebhookSecret() { return abacatePayWebhookSecret; }
public String appPublicUrl() { return appPublicUrl; }
public String evolutionGlobalApiKey() { return evolutionGlobalApiKey; }
public String evolutionApiUrl() { return evolutionApiUrl; }
}
@@ -0,0 +1,43 @@
package com.seconectai.condocontab.config;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
public final class DataSourceProvider {
private final AppConfig config;
public DataSourceProvider(AppConfig config) {
this.config = config;
}
public Connection getMasterConnection() throws SQLException {
return DriverManager.getConnection(config.masterJdbcUrl(), config.dbUser(), config.dbPassword());
}
public void testarConexaoMaster() throws SQLException {
try (Connection conn = getMasterConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("SELECT 1");
System.out.println("Conexao com db_master OK");
}
}
public void aguardarConexaoMaster(int tentativas, long intervaloMillis) throws SQLException, InterruptedException {
SQLException ultimoErro = null;
for (int tentativa = 1; tentativa <= tentativas; tentativa++) {
try {
testarConexaoMaster();
return;
} catch (SQLException e) {
ultimoErro = e;
System.out.println("Aguardando db_master ficar disponivel (" + tentativa + "/" + tentativas + ")");
Thread.sleep(intervaloMillis);
}
}
throw ultimoErro;
}
// Prompt 03 troca este ponto por cache/pool por tenant via TenantDataSourceProvider.
}
@@ -0,0 +1,153 @@
package com.seconectai.condocontab.db;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
public final class MigrationRunner {
private MigrationRunner() {
}
public static void runMigrations(Connection conn, String diretorioResources) throws SQLException, IOException {
criarTabelaControle(conn);
List<String> migrations = listarMigrations(diretorioResources);
if (migrations.isEmpty()) {
System.out.println("Nenhuma migration encontrada em " + diretorioResources + " (no-op)");
return;
}
for (String migration : migrations) {
if (jaExecutada(conn, migration)) {
continue;
}
executarMigration(conn, diretorioResources, migration);
registrarExecucao(conn, migration);
System.out.println("Migration executada: " + migration);
}
}
private static void criarTabelaControle(Connection conn) throws SQLException {
try (Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
id BIGSERIAL PRIMARY KEY,
nome_arquivo VARCHAR(255) NOT NULL UNIQUE,
executado_em TIMESTAMP NOT NULL DEFAULT now()
)
""");
}
}
private static List<String> listarMigrations(String diretorioResources) throws IOException {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
URL url = classLoader.getResource(diretorioResources);
if (url == null) {
return List.of();
}
if ("file".equals(url.getProtocol())) {
try {
Path dir = Path.of(url.toURI());
try (Stream<Path> stream = Files.list(dir)) {
return stream
.filter(path -> path.getFileName().toString().endsWith(".sql"))
.map(path -> path.getFileName().toString())
.sorted()
.toList();
}
} catch (URISyntaxException e) {
throw new IOException("Diretorio de migrations invalido: " + diretorioResources, e);
}
}
if ("jar".equals(url.getProtocol())) {
return listarMigrationsNoJar(diretorioResources);
}
return List.of();
}
private static List<String> listarMigrationsNoJar(String diretorioResources) throws IOException {
List<String> migrations = new ArrayList<>();
String self = MigrationRunner.class.getProtectionDomain().getCodeSource().getLocation().getPath();
URI jarUri = URI.create("jar:file:" + self);
try (var fileSystem = java.nio.file.FileSystems.newFileSystem(jarUri, Collections.emptyMap())) {
Path dir = fileSystem.getPath(diretorioResources);
if (!Files.exists(dir)) {
return List.of();
}
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(path -> path.getFileName().toString().endsWith(".sql"))
.map(path -> path.getFileName().toString())
.sorted()
.forEach(migrations::add);
}
}
return migrations;
}
private static boolean jaExecutada(Connection conn, String nomeArquivo) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT 1 FROM schema_migrations WHERE nome_arquivo = ?")) {
ps.setString(1, nomeArquivo);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
private static void executarMigration(Connection conn, String diretorioResources, String nomeArquivo)
throws IOException, SQLException {
String resource = diretorioResources + "/" + nomeArquivo;
String sql = readResource(resource);
boolean autoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
try (Statement stmt = conn.createStatement()) {
stmt.execute(sql);
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(autoCommit);
}
}
private static String readResource(String resource) throws IOException {
InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (input == null) {
throw new IOException("Migration nao encontrada: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
StringBuilder sql = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sql.append(line).append('\n');
}
return sql.toString();
}
}
private static void registrarExecucao(Connection conn, String nomeArquivo) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO schema_migrations (nome_arquivo) VALUES (?)")) {
ps.setString(1, nomeArquivo);
ps.executeUpdate();
}
}
}
@@ -0,0 +1,88 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Base64;
public final class AbacatePayClient {
private final AppConfig config;
private final DataSourceProvider dataSourceProvider;
private final HttpClient http = HttpClient.newHttpClient();
public AbacatePayClient(AppConfig config, DataSourceProvider dataSourceProvider) {
this.config = config;
this.dataSourceProvider = dataSourceProvider;
}
public Cobranca criarCobranca(long valorCentavos, String descricao, String identificadorExterno) throws Exception {
String apiKey = setting("abacatepay_api_key", config.abacatePayApiKey());
if (apiKey.isBlank()) {
String id = "mock_" + identificadorExterno;
String payload = "000201 MOCK PIX " + valorCentavos + " " + identificadorExterno;
String base64 = Base64.getEncoder().encodeToString(("QR:" + payload).getBytes(StandardCharsets.UTF_8));
return new Cobranca(id, payload, base64);
}
String json = "{\"amount\":" + valorCentavos
+ ",\"description\":\"" + esc(descricao) + "\""
+ ",\"expiresIn\":86400"
+ ",\"metadata\":{\"externalId\":\"" + esc(identificadorExterno) + "\"}}";
HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.abacatepay.com/v1/pixQrCode/create"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() < 200 || res.statusCode() >= 300) {
throw new IllegalStateException("AbacatePay HTTP " + res.statusCode() + ": " + res.body());
}
String body = res.body();
return new Cobranca(find(body, "id"), find(body, "brcode"), find(body, "brcode_base64"));
}
public boolean validarWebhookSecret(String secret) {
try {
return setting("abacatepay_webhook_secret", config.abacatePayWebhookSecret()).equals(secret);
} catch (Exception e) {
return config.abacatePayWebhookSecret().equals(secret);
}
}
private String setting(String chave, String fallback) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT valor FROM configuracoes_plataforma WHERE chave=?")) {
ps.setString(1, chave);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String valor = rs.getString(1);
if (valor != null && !valor.isBlank()) return valor;
}
}
}
return fallback == null ? "" : fallback;
}
private String esc(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String find(String json, String key) {
String marker = "\"" + key + "\"";
int p = json.indexOf(marker);
if (p < 0) return null;
int c = json.indexOf(':', p);
int q = json.indexOf('"', c + 1);
int e = json.indexOf('"', q + 1);
return q < 0 || e < 0 ? null : json.substring(q + 1, e);
}
public record Cobranca(String chargeId, String qrCodePayload, String qrCodeBase64) {}
}
@@ -0,0 +1,3 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.DataSourceProvider;import com.seconectai.condocontab.http.HttpResponses;import com.seconectai.condocontab.util.SimpleJson;import com.sun.net.httpserver.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.sql.*;import java.util.*;
public final class AbacatePayWebhookHandler implements HttpHandler{private final DataSourceProvider ds;private final AbacatePayClient abacate;public AbacatePayWebhookHandler(DataSourceProvider ds,AbacatePayClient abacate){this.ds=ds;this.abacate=abacate;}public void handle(HttpExchange ex)throws IOException{try{String secret=query(ex).get("webhookSecret");if(!abacate.validarWebhookSecret(secret)){HttpResponses.json(ex,401,Map.of("erro","webhook_invalido"));return;}String body=new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);String charge=extractCharge(body);try(Connection c=ds.getMasterConnection()){Map<String,Object> p=one(c,"SELECT * FROM pagamentos WHERE abacatepay_charge_id=?",charge);if(p==null){HttpResponses.json(ex,404,Map.of("erro","pagamento_nao_encontrado"));return;}long pid=((Number)p.get("id")).longValue(), per=((Number)p.get("periodo_uso_id")).longValue(), emp=((Number)p.get("empresa_id")).longValue();try(PreparedStatement ps=c.prepareStatement("UPDATE pagamentos SET status='pago', pago_em=COALESCE(pago_em,now()) WHERE id=?")){ps.setLong(1,pid);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now() WHERE id=?")){ps.setLong(1,per);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")){ps.setLong(1,emp);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}}catch(Exception e){e.printStackTrace(System.err);HttpResponses.json(ex,500,Map.of("erro","erro_webhook"));}}private String extractCharge(String body){try{Map<String,String>m=SimpleJson.parseObject(body);for(String k:List.of("chargeId","id","abacatepay_charge_id"))if(m.get(k)!=null)return m.get(k);}catch(Exception ignored){}String[]keys={"id","chargeId"};for(String k:keys){String m="\""+k+"\"";int p=body.indexOf(m);if(p>=0){int c=body.indexOf(':',p),q=body.indexOf('"',c+1),e=body.indexOf('"',q+1);if(q>=0&&e>q)return body.substring(q+1,e);}}return null;}private Map<String,String>query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(kv[0],kv.length>1?kv[1]:"");}return q;}private Map<String,Object>one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.length;i++)ps.setObject(i+1,params[i]);try(ResultSet rs=ps.executeQuery()){if(!rs.next())return null;Map<String,Object>m=new HashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}}}}
@@ -0,0 +1,96 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.sql.*;
import java.time.LocalDate;
public final class BloqueioJob implements Runnable {
private final DataSourceProvider ds;
public BloqueioJob(DataSourceProvider ds) {
this.ds = ds;
}
public void run() {
try {
System.out.println("BloqueioJob bloqueou " + executarAgora() + " empresas");
} catch (Exception e) {
e.printStackTrace(System.err);
}
}
public int executarAgora() throws SQLException {
int n = 0;
try (Connection c = ds.getMasterConnection();
PreparedStatement ps = c.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status='ativa'")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long emp = rs.getLong(1);
long per = periodo(c, emp);
PeriodoStatus st = status(c, per);
if (LocalDate.now().isAfter(st.dataVencimento()) && !"pago".equals(st.status())) {
try (PreparedStatement u = c.prepareStatement("UPDATE periodos_uso SET status='bloqueado', data_bloqueio=now() WHERE id=?; UPDATE empresas SET status='bloqueada' WHERE id=?")) {
u.setLong(1, per);
u.setLong(2, emp);
u.execute();
n++;
}
}
}
}
}
return n;
}
private long periodo(Connection c, long emp) throws SQLException {
int dia = diaVencimento(c, emp);
LocalDate venc = vencimentoAtual(dia);
LocalDate inicio = venc.plusDays(1);
LocalDate fim = venc.plusMonths(1);
LocalDate mes = venc.withDayOfMonth(1);
try (PreparedStatement ps = c.prepareStatement("INSERT INTO periodos_uso (empresa_id,mes_referencia,data_inicio,data_fim,data_vencimento,status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id,mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")) {
ps.setLong(1, emp);
ps.setDate(2, java.sql.Date.valueOf(mes));
ps.setDate(3, java.sql.Date.valueOf(inicio));
ps.setDate(4, java.sql.Date.valueOf(fim));
ps.setDate(5, java.sql.Date.valueOf(venc));
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
}
private LocalDate vencimentoAtual(int dia) {
LocalDate hoje = LocalDate.now();
LocalDate base = hoje.withDayOfMonth(Math.min(dia, hoje.lengthOfMonth()));
if (hoje.isBefore(base)) {
LocalDate anterior = hoje.minusMonths(1);
base = anterior.withDayOfMonth(Math.min(dia, anterior.lengthOfMonth()));
}
return base;
}
private int diaVencimento(Connection c, long emp) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1")) {
ps.setLong(1, emp);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 7;
return Math.max(1, Math.min(28, rs.getInt(1)));
}
}
}
private PeriodoStatus status(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT status,data_vencimento FROM periodos_uso WHERE id=?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return new PeriodoStatus(rs.getString(1), rs.getDate(2).toLocalDate());
}
}
}
private record PeriodoStatus(String status, LocalDate dataVencimento) {}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
package com.seconectai.condocontab.http;
import com.seconectai.condocontab.util.Json;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class HttpResponses {
private HttpResponses() {
}
public static void json(HttpExchange exchange, int status, Map<String, Object> body) throws IOException {
json(exchange, status, Json.write(body));
}
public static void json(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(bytes);
}
}
public static void methodNotAllowed(HttpExchange exchange) throws IOException {
exchange.sendResponseHeaders(405, -1);
}
}
@@ -0,0 +1,59 @@
package com.seconectai.condocontab.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
public final class Router implements HttpHandler {
private final Map<String, HttpHandler> exactRoutes = new LinkedHashMap<>();
private final Map<String, HttpHandler> prefixRoutes = new LinkedHashMap<>();
private HttpHandler fallbackHandler;
public void get(String path, HttpHandler handler) {
route("GET", path, handler);
}
public void post(String path, HttpHandler handler) {
route("POST", path, handler);
}
public void route(String method, String path, HttpHandler handler) {
exactRoutes.put(method.toUpperCase() + " " + path, handler);
}
public void prefix(String prefix, HttpHandler handler) {
prefixRoutes.put(prefix, handler);
}
public void fallback(HttpHandler handler) {
this.fallbackHandler = handler;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String key = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
HttpHandler exact = exactRoutes.get(key);
if (exact != null) {
exact.handle(exchange);
return;
}
String path = exchange.getRequestURI().getPath();
for (Map.Entry<String, HttpHandler> entry : prefixRoutes.entrySet()) {
if (path.startsWith(entry.getKey())) {
entry.getValue().handle(exchange);
return;
}
}
if (fallbackHandler != null) {
fallbackHandler.handle(exchange);
return;
}
exchange.sendResponseHeaders(404, -1);
}
}
@@ -0,0 +1,65 @@
package com.seconectai.condocontab.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class StaticFileHandler implements HttpHandler {
private static final Map<String, String> CONTENT_TYPES = Map.of(
"html", "text/html; charset=utf-8",
"css", "text/css; charset=utf-8",
"js", "application/javascript; charset=utf-8",
"json", "application/json; charset=utf-8",
"png", "image/png"
);
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod()) && !"HEAD".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
String path = URLDecoder.decode(exchange.getRequestURI().getPath(), StandardCharsets.UTF_8);
if (path.equals("/") || path.isBlank()) {
path = "/index.html";
}
if (path.contains("..")) {
exchange.sendResponseHeaders(400, -1);
return;
}
String resourcePath = "webapp" + path;
try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) {
if (input == null) {
exchange.sendResponseHeaders(404, -1);
return;
}
byte[] body = input.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", contentType(path));
if ("HEAD".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(200, -1);
return;
}
exchange.sendResponseHeaders(200, body.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(body);
}
}
}
private String contentType(String path) {
int dot = path.lastIndexOf('.');
if (dot < 0 || dot == path.length() - 1) {
return "application/octet-stream";
}
return CONTENT_TYPES.getOrDefault(path.substring(dot + 1), "application/octet-stream");
}
}
@@ -0,0 +1,36 @@
package com.seconectai.condocontab.integracao;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.PasswordAuthentication;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import java.util.Map;
import java.util.Properties;
public final class EmailClient {
public void enviar(Map<String, String> smtp, String destino, String assunto, String texto) throws MessagingException {
if (smtp == null || smtp.getOrDefault("smtp_host", "").isBlank()) return;
Properties props = new Properties();
props.put("mail.smtp.host", smtp.get("smtp_host"));
props.put("mail.smtp.port", smtp.getOrDefault("smtp_port", "587"));
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", smtp.getOrDefault("smtp_starttls", "true"));
String user = smtp.getOrDefault("smtp_user", "");
String pass = smtp.getOrDefault("smtp_password", "");
Session session = Session.getInstance(props, new jakarta.mail.Authenticator() {
@Override protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(user, pass);
}
});
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress(smtp.getOrDefault("smtp_from", user)));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(destino));
message.setSubject(assunto == null ? "Aviso" : assunto, "UTF-8");
message.setText(texto == null ? "" : texto, "UTF-8");
Transport.send(message);
}
}
@@ -0,0 +1,219 @@
package com.seconectai.condocontab.integracao;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.util.Json;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.qrcode.QRCodeWriter;
import java.io.IOException;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
public final class EvolutionApiClient {
private final String globalApiKey;
private final String defaultBaseUrl;
private final HttpClient httpClient;
public EvolutionApiClient(AppConfig config) {
this.globalApiKey = config.evolutionGlobalApiKey();
this.defaultBaseUrl = config.evolutionApiUrl();
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
public CriacaoInstancia criarInstancia(String baseUrl, String instanceName, String token, String numero) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
return new CriacaoInstancia(instanceName, token, qr, true);
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("instanceName", instanceName);
payload.put("integration", "WHATSAPP-BAILEYS");
payload.put("qrcode", true);
payload.put("number", onlyDigits(numero));
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/instance/create"))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json")
.header("apikey", globalApiKey)
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return new CriacaoInstancia(instanceName, firstNonBlank(extractJsonString(resp.body(), "hash"), token), qrBase64(resp.body()), false);
}
public Map<String, Object> conectar(String baseUrl, String instanceName, String numero) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
return Map.of("instance_name", instanceName, "qrcode_base64", qr, "mock", true);
}
String url = clean(baseUrl) + "/instance/connect/" + instanceName;
String digits = onlyDigits(numero);
if (!digits.isBlank()) {
url += "?number=" + digits;
}
HttpResponse<String> resp = sendNoBody("GET", url, globalApiKey);
Map<String, Object> out = new LinkedHashMap<>();
out.put("instance_name", instanceName);
out.put("pairing_code", extractJsonString(resp.body(), "pairingCode"));
out.put("qr_code_payload", extractJsonString(resp.body(), "code"));
out.put("qr_code_base64", qrBase64(resp.body()));
out.put("raw", resp.body());
return out;
}
public Map<String, Object> logout(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("DELETE", baseUrl, "/instance/logout/" + instanceName, instanceName);
}
public Map<String, Object> excluirInstancia(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("DELETE", baseUrl, "/instance/delete/" + instanceName, instanceName);
}
public Map<String, Object> reiniciar(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("POST", baseUrl, "/instance/restart/" + instanceName, instanceName);
}
public Map<String, Object> estado(String baseUrl, String instanceName) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
return Map.of("instance_name", instanceName, "state", "mock");
}
HttpResponse<String> resp = sendNoBody("GET", clean(baseUrl) + "/instance/connectionState/" + instanceName, globalApiKey);
Map<String, Object> out = new LinkedHashMap<>();
out.put("instance_name", instanceName);
out.put("state", firstNonBlank(extractJsonString(resp.body(), "state"), extractJsonString(resp.body(), "connectionStatus")));
out.put("raw", resp.body());
return out;
}
private Map<String, Object> instanceAction(String method, String baseUrl, String path, String instanceName) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
return Map.of("instance_name", instanceName, "ok", true, "mock", true);
}
HttpResponse<String> resp = sendNoBody(method, clean(baseUrl) + path, globalApiKey);
return Map.of("instance_name", instanceName, "ok", true, "raw", resp.body());
}
private HttpResponse<String> sendNoBody(String method, String url, String apiKey) throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(20))
.header("apikey", apiKey);
if ("GET".equals(method)) {
builder.GET();
} else if ("DELETE".equals(method)) {
builder.DELETE();
} else {
builder.method(method, HttpRequest.BodyPublishers.noBody());
}
HttpResponse<String> resp = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return resp;
}
public String enviarTexto(String baseUrl, String instanceName, String token, String numero, String texto) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) return "mock";
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("number", onlyDigits(numero));
payload.put("text", texto);
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/message/sendText/" + instanceName))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json")
.header("apikey", globalApiKey)
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return resp.body();
}
private boolean mockMode(String baseUrl) {
return globalApiKey == null || globalApiKey.isBlank() || baseUrl == null || baseUrl.isBlank() || baseUrl.startsWith("mock://");
}
private String clean(String baseUrl) {
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
public static String onlyDigits(String raw) {
return raw == null ? "" : raw.replaceAll("\\D", "");
}
private String firstNonBlank(String first, String fallback) {
return first == null || first.isBlank() ? fallback : first;
}
private String qrBase64(String responseBody) throws IOException {
String image = firstNonBlank(extractJsonString(responseBody, "base64"), extractJsonString(responseBody, "qrcode"));
if (looksLikeImageBase64(image)) {
return stripDataImagePrefix(image);
}
String payload = extractJsonString(responseBody, "code");
if (payload == null || payload.isBlank()) {
return null;
}
try {
var matrix = new QRCodeWriter().encode(payload, BarcodeFormat.QR_CODE, 320, 320);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
MatrixToImageWriter.writeToStream(matrix, "PNG", out);
return Base64.getEncoder().encodeToString(out.toByteArray());
}
} catch (Exception e) {
throw new IOException("Falha ao gerar QR Code local", e);
}
}
private boolean looksLikeImageBase64(String value) {
if (value == null || value.isBlank()) return false;
String clean = stripDataImagePrefix(value);
return clean.startsWith("iVBOR") || clean.startsWith("/9j/") || clean.startsWith("PHN2Zy");
}
private String stripDataImagePrefix(String value) {
if (value == null) return null;
int comma = value.indexOf(',');
return value.startsWith("data:image/") && comma > 0 ? value.substring(comma + 1) : value;
}
private String extractJsonString(String raw, String key) {
String marker = "\"" + key + "\"";
int p = raw.indexOf(marker);
if (p < 0) return null;
int colon = raw.indexOf(':', p);
int first = raw.indexOf('"', colon + 1);
int second = raw.indexOf('"', first + 1);
if (first < 0 || second < 0) return null;
return raw.substring(first + 1, second);
}
public record CriacaoInstancia(String instanceName, String token, String qrcodeBase64, boolean mock) {}
}
@@ -0,0 +1,82 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Empresa;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class EmpresaDao {
private final Connection conn;
public EmpresaDao(Connection conn) {
this.conn = conn;
}
public Empresa inserir(Empresa empresa) throws SQLException {
String sql = """
INSERT INTO empresas (nome, cnpj, slug, database_name, status)
VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, empresa.nome());
ps.setString(2, empresa.cnpj());
ps.setString(3, empresa.slug());
ps.setString(4, empresa.databaseName());
ps.setString(5, empresa.status() == null ? "ativa" : empresa.status());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para empresa");
}
return buscarPorId(keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Empresa> buscarPorId(long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Empresa> listarTodas() throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas ORDER BY id")) {
try (ResultSet rs = ps.executeQuery()) {
List<Empresa> empresas = new ArrayList<>();
while (rs.next()) {
empresas.add(map(rs));
}
return empresas;
}
}
}
public void atualizarStatus(long id, String status) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status = ? WHERE id = ?")) {
ps.setString(1, status);
ps.setLong(2, id);
ps.executeUpdate();
}
}
private Empresa map(ResultSet rs) throws SQLException {
return new Empresa(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("cnpj"),
rs.getString("slug"),
rs.getString("database_name"),
rs.getString("status"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,49 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Funcionalidade;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class FuncionalidadeDao {
private final Connection conn;
public FuncionalidadeDao(Connection conn) {
this.conn = conn;
}
public List<Funcionalidade> listarTodas() throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades ORDER BY chave")) {
try (ResultSet rs = ps.executeQuery()) {
List<Funcionalidade> funcionalidades = new ArrayList<>();
while (rs.next()) {
funcionalidades.add(map(rs));
}
return funcionalidades;
}
}
}
public Optional<Funcionalidade> buscarPorChave(String chave) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades WHERE chave = ?")) {
ps.setString(1, chave);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Funcionalidade map(ResultSet rs) throws SQLException {
return new Funcionalidade(
rs.getLong("id"),
rs.getString("chave"),
rs.getString("nome"),
rs.getString("descricao")
);
}
}
@@ -0,0 +1,88 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Funcionario;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class FuncionarioDao {
private final Connection conn;
public FuncionarioDao(Connection conn) {
this.conn = conn;
}
public Funcionario inserir(Funcionario funcionario) throws SQLException {
String sql = """
INSERT INTO funcionarios (empresa_id, nome, email, telefone, senha_hash, cargo, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, funcionario.empresaId());
ps.setString(2, funcionario.nome());
ps.setString(3, funcionario.email());
ps.setString(4, funcionario.telefone());
ps.setString(5, funcionario.senhaHash());
ps.setString(6, funcionario.cargo());
ps.setString(7, funcionario.status() == null ? "ativo" : funcionario.status());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para funcionario");
}
return buscarPorId(keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Funcionario> buscarPorEmail(String email) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE email = ?")) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Funcionario> listarPorEmpresa(long empresaId) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE empresa_id = ? ORDER BY id")) {
ps.setLong(1, empresaId);
try (ResultSet rs = ps.executeQuery()) {
List<Funcionario> funcionarios = new ArrayList<>();
while (rs.next()) {
funcionarios.add(map(rs));
}
return funcionarios;
}
}
}
private Optional<Funcionario> buscarPorId(long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Funcionario map(ResultSet rs) throws SQLException {
return new Funcionario(
rs.getLong("id"),
rs.getLong("empresa_id"),
rs.getString("nome"),
rs.getString("email"),
rs.getString("telefone"),
rs.getString("senha_hash"),
rs.getString("cargo"),
rs.getString("status"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,519 @@
package com.seconectai.condocontab.master.http;
import com.seconectai.condocontab.auth.PasswordHasher;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.master.dao.EmpresaDao;
import com.seconectai.condocontab.master.dao.FuncionarioDao;
import com.seconectai.condocontab.master.model.Empresa;
import com.seconectai.condocontab.master.model.Funcionario;
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.time.LocalDate;
import java.util.*;
public final class MasterHandlers implements HttpHandler {
private final DataSourceProvider dataSourceProvider;
private final TenantProvisioner tenantProvisioner;
private final EvolutionApiClient evolutionApiClient;
public MasterHandlers(DataSourceProvider dataSourceProvider, TenantProvisioner tenantProvisioner, EvolutionApiClient evolutionApiClient) {
this.dataSourceProvider = dataSourceProvider;
this.tenantProvisioner = tenantProvisioner;
this.evolutionApiClient = evolutionApiClient;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
route(exchange);
} catch (IllegalArgumentException e) {
HttpResponses.json(exchange, 400, Map.of("erro", "requisicao_invalida", "mensagem", e.getMessage()));
} catch (SQLException e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_banco", "mensagem", e.getMessage()));
} catch (Exception e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_interno", "mensagem", e.getMessage()));
}
}
private void route(HttpExchange ex) throws Exception {
String method = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
String rest = path.substring("/api/master".length());
if (rest.isEmpty()) rest = "/";
String[] parts = Arrays.stream(rest.split("/"))
.filter(s -> !s.isBlank())
.map(s -> URLDecoder.decode(s, StandardCharsets.UTF_8))
.toArray(String[]::new);
if (parts.length == 1 && "empresas".equals(parts[0])) {
if ("POST".equals(method)) criarEmpresa(ex);
else if ("GET".equals(method)) listarEmpresas(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 2 && "empresas".equals(parts[0])) {
long id = parseId(parts[1]);
if ("GET".equals(method)) obterEmpresa(ex, id);
else if ("PUT".equals(method)) atualizarEmpresa(ex, id);
else if ("DELETE".equals(method)) arquivarEmpresa(ex, id);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionarios".equals(parts[2])) {
long id = parseId(parts[1]);
if ("POST".equals(method)) criarFuncionario(ex, id);
else if ("GET".equals(method)) listarFuncionarios(ex, id);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "GET".equals(method)) {
listarFuncionalidadesEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 5 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "POST".equals(method)) {
alterarFuncionalidadeEmpresa(ex, parseId(parts[1]), parts[3], "ativar".equals(parts[4]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "lembrete-cobranca".equals(parts[2]) && "POST".equals(method)) {
registrarLembrete(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "vencimento".equals(parts[2]) && "PUT".equals(method)) {
atualizarVencimentoEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "status".equals(parts[2]) && "POST".equals(method)) {
alterarStatusEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "desbloquear".equals(parts[2]) && "POST".equals(method)) {
desbloquearEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "GET".equals(method)) {
listarEvolutionInstancias(ex, parseId(parts[1]));
return;
}
if (parts.length == 5 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "POST".equals(method)) {
alterarEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]), "aprovar".equals(parts[4]) ? "aprovada" : "rejeitada");
return;
}
if (parts.length == 4 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "DELETE".equals(method)) {
excluirEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]));
return;
}
if (parts.length == 1 && "configuracoes".equals(parts[0])) {
if ("GET".equals(method)) listarConfiguracoes(ex);
else if ("PUT".equals(method)) salvarConfiguracoes(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 1 && "planos".equals(parts[0])) {
if ("GET".equals(method)) listarPlanos(ex);
else if ("POST".equals(method)) criarPlano(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 2 && "planos".equals(parts[0]) && "PUT".equals(method)) {
atualizarPlano(ex, parseId(parts[1]));
return;
}
if (parts.length == 1 && "logs-envio".equals(parts[0]) && "GET".equals(method)) {
listarLogsEnvio(ex);
return;
}
HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado"));
}
private void listarEvolutionInstancias(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, numero_whatsapp, status, solicitado_em, aprovado_em, aprovado_por FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC, id DESC")) {
ps.setLong(1, empresaId);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(resultMap(rs)); }
HttpResponses.json(ex, 200, Map.of("instancias", rows));
}
}
private void excluirEvolutionInstancia(HttpExchange ex, long empresaId, long instId) throws Exception {
Map<String,Object> inst;
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, instance_token_enc, numero_whatsapp FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
ps.setLong(1, instId);
ps.setLong(2, empresaId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada"));
return;
}
inst = resultMap(rs);
}
}
String instanceToken = String.valueOf(inst.get("instance_token_enc"));
String instanceName = instanceToken.split(":", 2)[0];
String evolutionErro = null;
try {
evolutionApiClient.excluirInstancia(String.valueOf(inst.get("instance_url")), instanceName);
} catch (Exception err) {
evolutionErro = err.getMessage();
String msg = evolutionErro == null ? "" : evolutionErro.toLowerCase(Locale.ROOT);
if (!(msg.contains("404") || msg.contains("not found") || msg.contains("não encontrada") || msg.contains("nao encontrada") || msg.contains("instance not found"))) {
HttpResponses.json(ex, 502, Map.of("erro", "falha_excluir_evolution", "mensagem", evolutionErro));
return;
}
}
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("DELETE FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
ps.setLong(1, instId);
ps.setLong(2, empresaId);
ps.executeUpdate();
}
Map<String,Object> resp = new LinkedHashMap<>();
resp.put("ok", true);
resp.put("evolution_instance", instanceName);
if (evolutionErro != null) resp.put("aviso", "Registro local removido; a Evolution informou que a instancia ja nao existia: " + evolutionErro);
HttpResponses.json(ex, 200, resp);
}
private void alterarEvolutionInstancia(HttpExchange ex, long empresaId, long instId, String status) throws Exception {
if (!"aprovada".equals(status) && !"rejeitada".equals(status)) { HttpResponses.json(ex, 400, Map.of("erro", "acao_invalida")); return; }
String sql = "UPDATE evolution_instancias SET status=?, aprovado_em=CASE WHEN ?='aprovada' THEN now() ELSE aprovado_em END, aprovado_por=NULL WHERE id=? AND empresa_id=?";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
ps.setString(1, status); ps.setString(2, status); ps.setLong(3, instId); ps.setLong(4, empresaId);
if (ps.executeUpdate() == 0) { conn.rollback(); HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada")); return; }
if ("aprovada".equals(status)) ativarFuncionalidade(conn, empresaId, "whatsapp_envio");
conn.commit();
listarEvolutionInstancias(ex, empresaId);
}
}
private void ativarFuncionalidade(Connection conn, long empresaId, String chave) throws SQLException {
String sql = """
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
SELECT ?, id, true, now(), NULL FROM funcionalidades WHERE chave=?
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=true, liberado_em=now()
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId);
ps.setString(2, chave);
ps.executeUpdate();
}
}
private void listarConfiguracoes(HttpExchange ex) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT chave, valor, atualizado_em FROM configuracoes_plataforma ORDER BY chave")) {
Map<String,Object> cfg = new LinkedHashMap<>();
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String chave = rs.getString("chave");
Object valor = Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) ? "********" : rs.getString("valor");
cfg.put(chave, valor);
Map<String,Object> row = new LinkedHashMap<>(); row.put("chave", chave); row.put("valor", valor); row.put("atualizado_em", rs.getObject("atualizado_em")); rows.add(row);
}
}
HttpResponses.json(ex, 200, Map.of("configuracoes", cfg, "itens", rows));
}
}
private void salvarConfiguracoes(HttpExchange ex) throws Exception {
Map<String,String> b = body(ex);
Set<String> permitidas = Set.of("smtp_host","smtp_port","smtp_user","smtp_password","smtp_from","smtp_starttls","abacatepay_api_key","abacatepay_webhook_secret");
try (Connection conn = dataSourceProvider.getMasterConnection()) {
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO configuracoes_plataforma (chave, valor, atualizado_em) VALUES (?, ?, now()) ON CONFLICT (chave) DO UPDATE SET valor=EXCLUDED.valor, atualizado_em=now()")) {
for (String chave : permitidas) {
if (!b.containsKey(chave)) continue;
String valor = b.get(chave);
if (Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) && (valor == null || valor.isBlank() || "********".equals(valor))) continue;
ps.setString(1, chave); ps.setString(2, valor); ps.addBatch();
}
ps.executeBatch();
}
}
listarConfiguracoes(ex);
}
private void criarEmpresa(HttpExchange ex) throws Exception {
Map<String, String> body = body(ex);
String nome = required(body, "nome");
String cnpj = body.get("cnpj");
String slug = required(body, "slug");
Long planoId = optionalLong(body.get("plano_id"));
String emailPrincipal = required(body, "email");
String senhaPrincipal = required(body, "senha");
String telefonePrincipal = body.get("telefone");
String dbName = tenantProvisioner.databaseNameFromSlug(slug);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
if (exists(conn, "SELECT 1 FROM empresas WHERE slug = ?", slug.replace('-', '_'))) {
HttpResponses.json(ex, 409, Map.of("erro", "slug_duplicado"));
return;
}
if (exists(conn, "SELECT 1 FROM funcionarios WHERE email = ?", emailPrincipal)) {
HttpResponses.json(ex, 409, Map.of("erro", "email_duplicado", "mensagem", "Este e-mail ja esta cadastrado como login."));
return;
}
EmpresaDao dao = new EmpresaDao(conn);
Empresa criada = dao.inserir(new Empresa(null, nome, cnpj, slug.replace('-', '_'), dbName, "provisionando", null));
var resultado = tenantProvisioner.provisionar(nome, slug);
String status = resultado.sucesso() ? "ativa" : "provisionamento_pendente";
dao.atualizarStatus(criada.id(), status);
if (planoId != null) { salvarAssinatura(conn, criada.id(), planoId); copiarFuncionalidadesPlano(conn, criada.id(), planoId); }
Funcionario principal = new FuncionarioDao(conn).inserir(new Funcionario(null, criada.id(), nome, emailPrincipal, telefonePrincipal, PasswordHasher.hash(senhaPrincipal), "admin_empresa", "ativo", null));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("empresa", empresaMap(dao.buscarPorId(criada.id()).orElseThrow()));
resp.put("funcionario_principal", funcionarioMap(principal));
resp.put("provisionamento", resultado.sucesso());
resp.put("erro", resultado.erro());
HttpResponses.json(ex, 201, resp);
}
}
private void listarEmpresas(HttpExchange ex) throws Exception {
Map<String, String> q = query(ex);
StringBuilder sql = new StringBuilder("""
SELECT e.*, p.id AS plano_id, p.nome AS plano_nome, p.valor_mensal_centavos AS plano_valor_centavos, a.dia_vencimento, pu.status AS periodo_status, pu.mes_referencia, pu.data_inicio, pu.data_fim, pu.data_vencimento
FROM empresas e
LEFT JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa'
LEFT JOIN planos p ON p.id=a.plano_id
LEFT JOIN LATERAL (SELECT * FROM periodos_uso pu WHERE pu.empresa_id=e.id ORDER BY pu.mes_referencia DESC LIMIT 1) pu ON true
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
if (q.containsKey("status")) { sql.append(" AND e.status=?"); params.add(q.get("status")); } else { sql.append(" AND e.status<>'excluida'"); }
if ("pagante".equals(q.get("situacao"))) { sql.append(" AND pu.status='pago'"); }
sql.append(" ORDER BY e.id");
try (Connection conn = dataSourceProvider.getMasterConnection()) {
garantirPeriodosAtuais(conn);
try (PreparedStatement ps = conn.prepareStatement(sql.toString())) {
bind(ps, params);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(empresaResumoMap(rs)); }
HttpResponses.json(ex, 200, Map.of("empresas", rows));
}
}
}
private void obterEmpresa(HttpExchange ex, long id) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
var emp = new EmpresaDao(conn).buscarPorId(id);
if (emp.isEmpty()) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
Map<String,Object> out = empresaMap(emp.get());
Map<String,Object> plano = one(conn, "SELECT p.id,p.nome,p.valor_mensal_centavos FROM assinaturas a JOIN planos p ON p.id=a.plano_id WHERE a.empresa_id=? AND a.status='ativa' ORDER BY a.id DESC LIMIT 1", List.of(id));
out.put("plano", plano);
HttpResponses.json(ex, 200, Map.of("empresa", out));
}
}
private void atualizarEmpresa(HttpExchange ex, long id) throws Exception {
Map<String,String> b = body(ex);
Long planoId = optionalLong(b.get("plano_id"));
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj) WHERE id=?")) {
ps.setString(1, emptyToNull(b.get("nome"))); ps.setString(2, emptyToNull(b.get("cnpj"))); ps.setLong(3, id); ps.executeUpdate();
}
if (planoId != null) { salvarAssinatura(conn, id, planoId); copiarFuncionalidadesPlano(conn, id, planoId); }
conn.commit();
obterEmpresa(ex, id);
}
}
private void arquivarEmpresa(HttpExchange ex, long id) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='excluida' WHERE id=?")) {
ps.setLong(1, id);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
HttpResponses.json(ex, 200, Map.of("ok", true, "status", "excluida"));
}
}
private void criarFuncionario(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b = body(ex);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
Funcionario f = new FuncionarioDao(conn).inserir(new Funcionario(null, empresaId, required(b,"nome"), required(b,"email"), b.get("telefone"), PasswordHasher.hash(required(b,"senha")), b.get("cargo"), "ativo", null));
HttpResponses.json(ex, 201, Map.of("funcionario", funcionarioMap(f)));
}
}
private void listarFuncionarios(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
List<Map<String,Object>> rows = new ArrayList<>();
for (Funcionario f : new FuncionarioDao(conn).listarPorEmpresa(empresaId)) rows.add(funcionarioMap(f));
HttpResponses.json(ex, 200, Map.of("funcionarios", rows));
}
}
private void listarFuncionalidadesEmpresa(HttpExchange ex, long empresaId) throws Exception {
String sql = """
SELECT f.chave, f.nome, f.descricao, COALESCE(ef.ativa,false) AS ativa
FROM funcionalidades f
LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=?
ORDER BY f.chave
""";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(Map.of("chave", rs.getString("chave"), "nome", rs.getString("nome"), "descricao", rs.getString("descricao"), "ativa", rs.getBoolean("ativa"))); }
HttpResponses.json(ex, 200, Map.of("funcionalidades", rows));
}
}
private void alterarFuncionalidadeEmpresa(HttpExchange ex, long empresaId, String chave, boolean ativa) throws Exception {
String sql = """
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
SELECT ?, id, ?, now(), NULL FROM funcionalidades WHERE chave=?
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=EXCLUDED.ativa, liberado_em=now()
""";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId); ps.setBoolean(2, ativa); ps.setString(3, chave);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "funcionalidade_nao_encontrada")); return; }
listarFuncionalidadesEmpresa(ex, empresaId);
}
}
private void criarPlano(HttpExchange ex) throws Exception { salvarPlano(ex, null); }
private void atualizarPlano(HttpExchange ex, long id) throws Exception { salvarPlano(ex, id); }
private void salvarPlano(HttpExchange ex, Long id) throws Exception {
String raw = rawBody(ex);
List<String> funcionalidades = stringArray(raw, "funcionalidades");
Map<String,String> b = SimpleJson.parseObject(removeArrayField(raw, "funcionalidades"));
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
long planoId = id == null ? inserirPlano(conn, b) : atualizarPlanoRow(conn, id, b);
sincronizarPlanoFuncionalidades(conn, planoId, funcionalidades);
conn.commit();
HttpResponses.json(ex, id == null ? 201 : 200, Map.of("plano", planoMap(conn, planoId)));
}
}
private void listarPlanos(HttpExchange ex) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM planos ORDER BY id")) {
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(planoMap(rs)); }
HttpResponses.json(ex, 200, Map.of("planos", rows));
}
}
private void listarLogsEnvio(HttpExchange ex) throws Exception {
Map<String,String> q=query(ex); int limit=(int)(optionalLong(q.get("limit"))==null?50:optionalLong(q.get("limit"))); int offset=(int)(optionalLong(q.get("offset"))==null?0:optionalLong(q.get("offset")));
StringBuilder sql=new StringBuilder("SELECT * FROM logs_envio_avisos WHERE 1=1"); List<Object> params=new ArrayList<>();
if(q.get("status")!=null){sql.append(" AND status=?");params.add(q.get("status"));}
if(q.get("empresa_id")!=null){sql.append(" AND empresa_id=?");params.add(Long.parseLong(q.get("empresa_id")));}
sql.append(" ORDER BY criado_em DESC LIMIT ? OFFSET ?"); params.add(limit); params.add(offset);
try(Connection conn=dataSourceProvider.getMasterConnection(); PreparedStatement ps=conn.prepareStatement(sql.toString())){ bind(ps,params); List<Map<String,Object>> rows=new ArrayList<>(); try(ResultSet rs=ps.executeQuery()){while(rs.next()) rows.add(resultMap(rs));} HttpResponses.json(ex,200,Map.of("logs",rows));}
}
private void atualizarVencimentoEmpresa(HttpExchange ex,long empresaId)throws Exception{
Map<String,String>b=body(ex);
int dia=Integer.parseInt(required(b,"dia_vencimento"));
if(dia<1||dia>28){HttpResponses.json(ex,400,Map.of("erro","dia_vencimento_invalido","mensagem","Informe um dia entre 1 e 28."));return;}
try(Connection conn=dataSourceProvider.getMasterConnection()){
conn.setAutoCommit(false);
try{
if(!exists(conn,"SELECT 1 FROM empresas WHERE id=?",empresaId)){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","empresa_nao_encontrada"));return;}
Map<String,Object>a=one(conn,"SELECT id FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));
if(a==null){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","assinatura_nao_encontrada","mensagem","Empresa sem assinatura ativa."));return;}
try(PreparedStatement ps=conn.prepareStatement("UPDATE assinaturas SET dia_vencimento=? WHERE id=?")){ps.setInt(1,dia);ps.setLong(2,((Number)a.get("id")).longValue());ps.executeUpdate();}
long periodo=periodoAtual(conn,empresaId);
conn.commit();
HttpResponses.json(ex,200,Map.of("ok",true,"empresa_id",empresaId,"dia_vencimento",dia,"periodo_uso_id",periodo));
}catch(Exception err){conn.rollback();throw err;}finally{conn.setAutoCommit(true);}
}
}
private void desbloquearEmpresa(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
try {
if (!exists(conn, "SELECT 1 FROM empresas WHERE id = ?", empresaId)) {
conn.rollback();
HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada"));
return;
}
long periodoId = periodoAtual(conn, empresaId);
try (PreparedStatement ps = conn.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now(), data_bloqueio=NULL WHERE id=?")) {
ps.setLong(1, periodoId);
ps.executeUpdate();
}
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")) {
ps.setLong(1, empresaId);
ps.executeUpdate();
}
conn.commit();
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "periodo_uso_id", periodoId, "status", "ativa", "liberacao", "manual"));
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
}
}
private void alterarStatusEmpresa(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b = body(ex);
String status = required(b, "status");
if (!Set.of("ativa", "bloqueada").contains(status)) {
HttpResponses.json(ex, 400, Map.of("erro", "status_invalido"));
return;
}
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status=? WHERE id=? AND status<>'excluida'")) {
ps.setString(1, status);
ps.setLong(2, empresaId);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "status", status));
}
}
private void registrarLembrete(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b=body(ex); String canal=required(b,"canal");
try(Connection conn=dataSourceProvider.getMasterConnection()){
long periodoId=periodoAtual(conn,empresaId);
// TODO Prompt 08: enviar lembrete real por WhatsApp/e-mail; nesta fase apenas registra a intenção.
try(PreparedStatement ps=conn.prepareStatement("INSERT INTO lembretes_cobranca (empresa_id, periodo_uso_id, canal, enviado_por) VALUES (?,?,?,NULL) RETURNING id")){ps.setLong(1,empresaId);ps.setLong(2,periodoId);ps.setString(3,canal);try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"registrado",true));}}
}
}
private void garantirPeriodosAtuais(Connection conn)throws SQLException{try(PreparedStatement ps=conn.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status<>'excluida' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next())periodoAtual(conn,rs.getLong(1));}}}
private long periodoAtual(Connection conn,long empresaId)throws SQLException{int dia=diaVencimento(conn,empresaId);LocalDate venc=vencimentoAtual(dia);LocalDate inicio=venc.plusDays(1);LocalDate fim=venc.plusMonths(1);LocalDate mes=venc.withDayOfMonth(1);try(PreparedStatement ps=conn.prepareStatement("INSERT INTO periodos_uso (empresa_id, mes_referencia, data_inicio, data_fim, data_vencimento, status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id, mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")){ps.setLong(1,empresaId);ps.setDate(2,java.sql.Date.valueOf(mes));ps.setDate(3,java.sql.Date.valueOf(inicio));ps.setDate(4,java.sql.Date.valueOf(fim));ps.setDate(5,java.sql.Date.valueOf(venc));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private LocalDate vencimentoAtual(int dia){LocalDate hoje=LocalDate.now();LocalDate base=hoje.withDayOfMonth(Math.min(dia,hoje.lengthOfMonth()));if(hoje.isBefore(base)){LocalDate anterior=hoje.minusMonths(1);base=anterior.withDayOfMonth(Math.min(dia,anterior.lengthOfMonth()));}return base;}
private int diaVencimento(Connection conn,long empresaId)throws SQLException{Map<String,Object>a=one(conn,"SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));if(a==null||a.get("dia_vencimento")==null)return 7;int dia=((Number)a.get("dia_vencimento")).intValue();return Math.max(1,Math.min(28,dia));}
private long inserirPlano(Connection c, Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO planos (nome,valor_mensal_centavos,descricao,ativo) VALUES (?,?,?,true) RETURNING id")){ps.setString(1,required(b,"nome"));ps.setLong(2,Long.parseLong(required(b,"valor_mensal_centavos")));ps.setString(3,b.get("descricao"));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long atualizarPlanoRow(Connection c,long id,Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE planos SET nome=COALESCE(?,nome), valor_mensal_centavos=COALESCE(?,valor_mensal_centavos), descricao=COALESCE(?,descricao) WHERE id=?")){ps.setString(1,b.get("nome")); if(b.get("valor_mensal_centavos")==null)ps.setNull(2,Types.BIGINT);else ps.setLong(2,Long.parseLong(b.get("valor_mensal_centavos"))); ps.setString(3,b.get("descricao")); ps.setLong(4,id); ps.executeUpdate(); return id;}}
private void sincronizarPlanoFuncionalidades(Connection c,long planoId,List<String> chaves)throws SQLException{try(PreparedStatement del=c.prepareStatement("DELETE FROM plano_funcionalidades WHERE plano_id=?")){del.setLong(1,planoId);del.executeUpdate();}try(PreparedStatement ins=c.prepareStatement("INSERT INTO plano_funcionalidades (plano_id,funcionalidade_id) SELECT ?, id FROM funcionalidades WHERE chave=?")){for(String chave:chaves){ins.setLong(1,planoId);ins.setString(2,chave);ins.addBatch();}ins.executeBatch();}}
private void copiarFuncionalidadesPlano(Connection c,long empresaId,long planoId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO empresa_funcionalidades (empresa_id,funcionalidade_id,ativa) SELECT ?, funcionalidade_id, true FROM plano_funcionalidades WHERE plano_id=? ON CONFLICT (empresa_id,funcionalidade_id) DO UPDATE SET ativa=true")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.executeUpdate();}}
private void salvarAssinatura(Connection c,long empresaId,long planoId)throws SQLException{Map<String,Object> p=one(c,"SELECT valor_mensal_centavos FROM planos WHERE id=?",List.of(planoId));if(p==null)throw new IllegalArgumentException("Plano nao encontrado");try(PreparedStatement ps=c.prepareStatement("UPDATE assinaturas SET status='inativa' WHERE empresa_id=? AND status='ativa'")){ps.setLong(1,empresaId);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO assinaturas (empresa_id,plano_id,valor_mensal_centavos,dia_vencimento,status) VALUES (?,?,?,?, 'ativa')")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.setLong(3,((Number)p.get("valor_mensal_centavos")).longValue());ps.setInt(4,7);ps.executeUpdate();}}
private Map<String,Object> empresaMap(Empresa e){Map<String,Object> m=new LinkedHashMap<>();m.put("id",e.id());m.put("nome",e.nome());m.put("cnpj",e.cnpj());m.put("slug",e.slug());m.put("database_name",e.databaseName());m.put("status",e.status());m.put("criado_em",e.criadoEm());return m;}
private Map<String,Object> funcionarioMap(Funcionario f){Map<String,Object> m=new LinkedHashMap<>();m.put("id",f.id());m.put("empresa_id",f.empresaId());m.put("nome",f.nome());m.put("email",f.email());m.put("telefone",f.telefone());m.put("cargo",f.cargo()==null?"":f.cargo());m.put("status",f.status());return m;}
private Map<String,Object> empresaResumoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("cnpj",rs.getString("cnpj"));m.put("slug",rs.getString("slug"));m.put("database_name",rs.getString("database_name"));m.put("status",rs.getString("status"));m.put("plano_id",rs.getObject("plano_id"));m.put("plano",rs.getString("plano_nome"));m.put("plano_valor_centavos",rs.getObject("plano_valor_centavos"));m.put("dia_vencimento",rs.getObject("dia_vencimento"));m.put("periodo_status",rs.getString("periodo_status"));m.put("mes_referencia",rs.getObject("mes_referencia"));m.put("data_inicio",rs.getObject("data_inicio"));m.put("data_fim",rs.getObject("data_fim"));m.put("data_vencimento",rs.getObject("data_vencimento"));return m;}
private Map<String,Object> planoMap(Connection c,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement("SELECT * FROM planos WHERE id=?")){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return planoMap(rs);}}}
private Map<String,Object> planoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("valor_mensal_centavos",rs.getLong("valor_mensal_centavos"));m.put("descricao",rs.getString("descricao"));m.put("ativo",rs.getBoolean("ativo"));return m;}
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++){m.put(md.getColumnLabel(i),rs.getObject(i));}return m;}
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private String emptyToNull(String v){return v==null||v.isBlank()?null:v;}
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));}
private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);}
private Map<String,String> query(HttpExchange ex){Map<String,String> q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
private String required(Map<String,String> m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;}
private Long optionalLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);}
private long parseId(String s){return Long.parseLong(s);}
private boolean exists(Connection c,String sql,String value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
private boolean exists(Connection c,String sql,long value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
private void bind(PreparedStatement ps,List<Object> params)throws SQLException{for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));}
private List<String> stringArray(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return List.of();int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return List.of();String body=raw.substring(start+1,end).trim();if(body.isBlank())return List.of();List<String> out=new ArrayList<>();for(String part:body.split(",")){String v=part.trim();if(v.startsWith("\"")&&v.endsWith("\""))v=v.substring(1,v.length()-1);if(!v.isBlank())out.add(v);}return out;}
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return raw;return raw.substring(0,start)+"\"\""+raw.substring(end+1);}
}
@@ -0,0 +1,14 @@
package com.seconectai.condocontab.master.model;
import java.time.LocalDateTime;
public record Empresa(
Long id,
String nome,
String cnpj,
String slug,
String databaseName,
String status,
LocalDateTime criadoEm
) {
}
@@ -0,0 +1,9 @@
package com.seconectai.condocontab.master.model;
public record Funcionalidade(
Long id,
String chave,
String nome,
String descricao
) {
}
@@ -0,0 +1,16 @@
package com.seconectai.condocontab.master.model;
import java.time.LocalDateTime;
public record Funcionario(
Long id,
Long empresaId,
String nome,
String email,
String telefone,
String senhaHash,
String cargo,
String status,
LocalDateTime criadoEm
) {
}
@@ -0,0 +1,77 @@
package com.seconectai.condocontab.tenant;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.db.MigrationRunner;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class TenantDataSourceProvider {
private final AppConfig config;
private final DataSourceProvider masterDataSource;
private final Map<Long, Connection> conexoesPorEmpresa = new ConcurrentHashMap<>();
public TenantDataSourceProvider(AppConfig config, DataSourceProvider masterDataSource) {
this.config = config;
this.masterDataSource = masterDataSource;
}
public Connection getConnection(long empresaId) throws SQLException {
Connection existente = conexoesPorEmpresa.get(empresaId);
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
return existente;
}
synchronized (conexoesPorEmpresa) {
existente = conexoesPorEmpresa.get(empresaId);
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
return existente;
}
String databaseName = buscarDatabaseName(empresaId);
Connection nova = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword());
executarMigrationsTenant(nova, databaseName);
return nova;
}
}
public void invalidar(long empresaId) {
Connection conn = conexoesPorEmpresa.remove(empresaId);
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
System.err.println("Falha ao fechar conexao tenant da empresa " + empresaId + ": " + e.getMessage());
}
}
}
private void executarMigrationsTenant(Connection conn, String databaseName) throws SQLException {
try {
MigrationRunner.runMigrations(conn, "migrations/tenant");
} catch (IOException e) {
throw new SQLException("Falha ao carregar migrations tenant para " + databaseName, e);
}
}
private String buscarDatabaseName(long empresaId) throws SQLException {
try (Connection master = masterDataSource.getMasterConnection();
PreparedStatement ps = master.prepareStatement("SELECT database_name FROM empresas WHERE id = ?")) {
ps.setLong(1, empresaId);
try (var rs = ps.executeQuery()) {
if (!rs.next()) {
throw new SQLException("Empresa nao encontrada no master: " + empresaId);
}
return rs.getString("database_name");
}
}
}
// Ponto de atencao futuro: invalidar automaticamente quando empresa for bloqueada/cancelada.
}
@@ -0,0 +1,61 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.Condominio;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class CondominioDao {
public Condominio inserir(Connection conn, Condominio condominio) throws SQLException {
String sql = "INSERT INTO condominios (nome, cnpj, endereco) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, condominio.nome());
ps.setString(2, condominio.cnpj());
ps.setString(3, condominio.endereco());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para condominio");
}
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Condominio> buscarPorId(Connection conn, long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Condominio> listarTodos(Connection conn) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios ORDER BY nome")) {
try (ResultSet rs = ps.executeQuery()) {
List<Condominio> condominios = new ArrayList<>();
while (rs.next()) {
condominios.add(map(rs));
}
return condominios;
}
}
}
private Condominio map(ResultSet rs) throws SQLException {
return new Condominio(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("cnpj"),
rs.getString("endereco"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,90 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.ContatoCondominio;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class ContatoCondominioDao {
public void vincular(Connection conn, ContatoCondominio vinculo) throws SQLException {
String sql = """
INSERT INTO contato_condominio
(contato_id, condominio_id, tipo, data_inicio_mandato, data_fim_mandato, status)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (contato_id, condominio_id, tipo) DO UPDATE SET
data_inicio_mandato = EXCLUDED.data_inicio_mandato,
data_fim_mandato = EXCLUDED.data_fim_mandato,
status = EXCLUDED.status
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, vinculo.contatoId());
ps.setLong(2, vinculo.condominioId());
ps.setString(3, vinculo.tipo());
ps.setDate(4, vinculo.dataInicioMandato() == null ? null : Date.valueOf(vinculo.dataInicioMandato()));
ps.setDate(5, vinculo.dataFimMandato() == null ? null : Date.valueOf(vinculo.dataFimMandato()));
ps.setString(6, vinculo.status() == null ? "ativo" : vinculo.status());
ps.executeUpdate();
}
}
public List<ContatoCondominio> listarPorCondominio(Connection conn, long condominioId) throws SQLException {
String sql = """
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
FROM contato_condominio cc
JOIN contatos c ON c.id = cc.contato_id
WHERE cc.condominio_id = ?
ORDER BY cc.tipo, c.nome
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, condominioId);
try (ResultSet rs = ps.executeQuery()) {
List<ContatoCondominio> vinculos = new ArrayList<>();
while (rs.next()) {
vinculos.add(map(rs));
}
return vinculos;
}
}
}
public Optional<ContatoCondominio> buscarSindicoAtual(Connection conn, long condominioId) throws SQLException {
String sql = """
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
FROM contato_condominio cc
JOIN contatos c ON c.id = cc.contato_id
WHERE cc.condominio_id = ?
AND cc.tipo = 'sindico'
AND cc.status = 'ativo'
ORDER BY cc.data_inicio_mandato DESC NULLS LAST, c.nome
LIMIT 1
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, condominioId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private ContatoCondominio map(ResultSet rs) throws SQLException {
Date inicio = rs.getDate("data_inicio_mandato");
Date fim = rs.getDate("data_fim_mandato");
return new ContatoCondominio(
rs.getLong("contato_id"),
rs.getLong("condominio_id"),
rs.getString("tipo"),
inicio == null ? null : inicio.toLocalDate(),
fim == null ? null : fim.toLocalDate(),
rs.getString("status"),
rs.getString("contato_nome"),
rs.getString("contato_telefone"),
rs.getString("contato_email")
);
}
}
@@ -0,0 +1,47 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.Contato;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Optional;
public final class ContatoDao {
public Contato inserir(Connection conn, Contato contato) throws SQLException {
String sql = "INSERT INTO contatos (nome, telefone, email) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, contato.nome());
ps.setString(2, contato.telefone());
ps.setString(3, contato.email());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para contato");
}
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Contato> buscarPorId(Connection conn, long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM contatos WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Contato map(ResultSet rs) throws SQLException {
return new Contato(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("telefone"),
rs.getString("email"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,521 @@
package com.seconectai.condocontab.tenant.http;
import com.seconectai.condocontab.auth.http.AuthMiddleware;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.integracao.EmailClient;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.util.Json;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
public final class ClienteHandlers implements HttpHandler {
private static final long MAX_UPLOAD = 10L * 1024L * 1024L;
private static final Set<String> EXT_OK = Set.of("pdf", "jpg", "jpeg", "png", "docx");
private final TenantDataSourceProvider tenantDs;
private final DataSourceProvider masterDs;
private final Path uploadRoot;
private final EvolutionApiClient evolutionApiClient;
private final EmailClient emailClient;
public ClienteHandlers(TenantDataSourceProvider tenantDs, DataSourceProvider masterDs, Path uploadRoot, EvolutionApiClient evolutionApiClient, EmailClient emailClient) {
this.tenantDs = tenantDs;
this.masterDs = masterDs;
this.uploadRoot = uploadRoot;
this.evolutionApiClient = evolutionApiClient;
this.emailClient = emailClient;
}
@Override public void handle(HttpExchange ex) throws IOException {
try { route(ex); }
catch (IllegalArgumentException e) { HttpResponses.json(ex,400,Map.of("erro","requisicao_invalida","mensagem",e.getMessage())); }
catch (SQLException e) { HttpResponses.json(ex,500,Map.of("erro","erro_banco","mensagem",e.getMessage())); }
catch (Throwable e) { e.printStackTrace(System.err); HttpResponses.json(ex,500,Map.of("erro","erro_interno","mensagem",String.valueOf(e.getMessage()))); }
}
private void route(HttpExchange ex) throws Exception {
String method=ex.getRequestMethod(); String path=ex.getRequestURI().getPath();
String[] p=Arrays.stream(path.substring("/api".length()).split("/")).filter(s->!s.isBlank()).map(s->URLDecoder.decode(s,StandardCharsets.UTF_8)).toArray(String[]::new);
long empresaId=(long)ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID); long funcionarioId=(long)ex.getAttribute(AuthMiddleware.ATTR_FUNCIONARIO_ID); String cargo=String.valueOf(ex.getAttribute(AuthMiddleware.ATTR_CARGO));
try(Connection c=tenantDs.getConnection(empresaId)){
if(p.length==1&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} if("POST".equals(method)) criarFuncionarioEmpresa(ex,empresaId); else if("GET".equals(method)) listarFuncionariosEmpresa(ex,empresaId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} long func=id(p[1]); if("PUT".equals(method)) atualizarFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else if("DELETE".equals(method)) excluirFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"lembrete-pessoas".equals(p[0])&&"GET".equals(method)){ listarPessoasLembrete(ex,empresaId); return; }
if(p.length==1&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} if("POST".equals(method)) criarExtra(ex,c,funcionarioId); else if("GET".equals(method)) listarExtras(ex,c); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} long extra=id(p[1]); if("PUT".equals(method)) atualizarExtra(ex,c,extra,funcionarioId); else if("DELETE".equals(method)) excluirExtra(ex,c,extra,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"lembretes".equals(p[0])){ if("POST".equals(method)) criarLembrete(ex,c,empresaId,funcionarioId); else if("GET".equals(method)) listarLembretes(ex,c,empresaId,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"lembretes".equals(p[0])&&"enviar".equals(p[2])&&"POST".equals(method)){ enviarLembreteManual(ex,c,empresaId,id(p[1]),cargo,funcionarioId); return; }
if(p.length==2&&"lembretes".equals(p[0])){ long lembrete=id(p[1]); if("PUT".equals(method)) atualizarLembrete(ex,c,empresaId,lembrete,cargo,funcionarioId); else if("DELETE".equals(method)) excluirLembrete(ex,c,lembrete,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode configurar o WhatsApp."));return;} }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"conectar".equals(p[2])&&"POST".equals(method)){ conectarEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"status".equals(p[2])&&"GET".equals(method)){ statusEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"qrcode".equals(p[2])&&"POST".equals(method)){ qrcodeEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"desconectar".equals(p[2])&&"POST".equals(method)){ desconectarEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"reconectar".equals(p[2])&&"POST".equals(method)){ reconectarEvolution(ex,empresaId); return; }
if(p.length==1&&"assembleias-calendario".equals(p[0])&&"GET".equals(method)){ listarAssembleiasCalendario(ex,c,empresaId,cargo,funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAviso(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarAvisos(ex,c,cond); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheAviso(ex,c,cond,id(p[3])); else if("DELETE".equals(method)) excluirAviso(ex,c,empresaId,cond,id(p[3]),funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==5&&"condominios".equals(p[0])&&"avisos".equals(p[2])&&"reenviar".equals(p[4])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} reenviarAviso(ex,c,empresaId,cond,id(p[3])); return; }
if(p.length==1&&"condominios".equals(p[0])){ if("POST".equals(method)) criarCondominio(ex,c,funcionarioId,cargo); else if("GET".equals(method)) listarCondominios(ex,c,funcionarioId,cargo); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"condominios".equals(p[0])){ long id=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,id)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheCondominio(ex,c,id); else if("PUT".equals(method)) atualizarCondominio(ex,c,id,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"contatos".equals(p[0])&&"POST".equals(method)){ criarContato(ex,c,funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"GET".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarContatos(ex,c,cond); return; }
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"vincular".equals(p[3])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} vincularContato(ex,c,cond,funcionarioId); return; }
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"DELETE".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} desvincularContato(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])){ long cond=id(p[1]); if("POST".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode vincular funcionarios a condominios."));return;} vincularFuncionario(ex,c,empresaId,cond,funcionarioId); } else if("GET".equals(method)){ if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarFuncionariosVinculados(ex,c,empresaId,cond); } else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])&&"DELETE".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode desvincular funcionarios de condominios."));return;} execWrite(ex,c,"DELETE FROM funcionario_condominio WHERE condominio_id=? AND funcionario_id=?",Arrays.asList(id(p[1]),id(p[3])),funcionarioId,"funcionario_condominio",id(p[3])); return; }
if(p.length==3&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarEspaco(ex,c,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM espacos_comuns WHERE condominio_id=? ORDER BY nome",cond,"espacos"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarEspaco(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"assembleias".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAssembleia(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM assembleias WHERE condominio_id=? ORDER BY data_hora DESC",cond,"assembleias"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"assembleias".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarAssembleia(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"documentos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) uploadDocumento(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM documentos WHERE condominio_id=? ORDER BY criado_em DESC",cond,"documentos"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"documentos".equals(p[0])&&"download".equals(p[2])&&"GET".equals(method)){ downloadDocumento(ex,c,id(p[1])); return; }
HttpResponses.json(ex,404,Map.of("erro","endpoint_nao_encontrado"));
}
}
private void criarCondominio(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{Map<String,String>b=body(ex);String cnpj=cnpj(req(b,"cnpj"));if(one(c,"SELECT id FROM condominios WHERE regexp_replace(cnpj,'\\D','','g')=?",Arrays.asList(cnpj))!=null){HttpResponses.json(ex,400,Map.of("erro","cnpj_duplicado","mensagem","Ja existe um condominio cadastrado com este CNPJ."));return;}c.setAutoCommit(false);try{long id=insert(c,"INSERT INTO condominios (nome,cnpj,endereco) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),cnpj,nullable(b.get("endereco"))));if(!adminEmpresa(cargo)){try(PreparedStatement ps=c.prepareStatement("INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,fid);ps.setLong(2,id);ps.executeUpdate();}}audit(c,fid,"create","condominios",id,null,b);c.commit();detalheCondominio(ex,c,id);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void listarCondominios(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{if(adminEmpresa(cargo)){listar(ex,c,"SELECT * FROM condominios ORDER BY nome","condominios");return;}HttpResponses.json(ex,200,Map.of("condominios",rows(c,"SELECT c.* FROM condominios c JOIN funcionario_condominio fc ON fc.condominio_id=c.id WHERE fc.funcionario_id=? ORDER BY c.nome",Arrays.asList(fid))));}
private boolean podeAcessarCondominio(Connection c,String cargo,long fid,long cond)throws SQLException{return adminEmpresa(cargo)||count(c,"SELECT count(*) FROM funcionario_condominio WHERE funcionario_id=? AND condominio_id=?",fid,cond)>0;}
private void detalheCondominio(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object> cond=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(cond==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} Map<String,Object> sind=one(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.tipo='sindico' AND cc.status='ativo' ORDER BY cc.data_inicio_mandato DESC NULLS LAST LIMIT 1",Arrays.asList(id)); long ass=count(c,"SELECT count(*) FROM assembleias WHERE condominio_id=? AND data_hora>now() AND status='agendada'",id); long av=count(c,"SELECT count(*) FROM avisos WHERE condominio_id=? AND (data_expiracao IS NULL OR data_expiracao>now())",id); Map<String,Object> resp=new LinkedHashMap<>();resp.put("condominio",cond);resp.put("sindico_atual",sind);resp.put("assembleias_futuras",ass);resp.put("avisos_ativos",av);HttpResponses.json(ex,200,resp);}
private void atualizarCondominio(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE condominios SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj), endereco=COALESCE(?,endereco) WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("cnpj"));ps.setString(3,b.get("endereco"));ps.setLong(4,id);ps.executeUpdate();} audit(c,fid,"update","condominios",id,old,b); detalheCondominio(ex,c,id);}
private void criarContato(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email")))); audit(c,fid,"create","contatos",id,null,b); HttpResponses.json(ex,201,Map.of("contato",one(c,"SELECT * FROM contatos WHERE id=?",Arrays.asList(id))));}
private void vincularContato(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); Long contato=optLong(b.get("contato_id")); if(contato==null){contato=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email"))));} String tipo=req(b,"tipo"); if("sindico".equals(tipo)){try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND tipo='sindico' AND status='ativo' AND contato_id<>?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.executeUpdate();}} try(PreparedStatement ps=c.prepareStatement("INSERT INTO contato_condominio (contato_id,condominio_id,tipo,data_inicio_mandato,data_fim_mandato,status) VALUES (?,?,?,?,?,'ativo') ON CONFLICT (contato_id,condominio_id,tipo) DO UPDATE SET data_inicio_mandato=EXCLUDED.data_inicio_mandato,data_fim_mandato=EXCLUDED.data_fim_mandato,status='ativo'")){ps.setLong(1,contato);ps.setLong(2,cond);ps.setString(3,tipo);setDate(ps,4,b.get("data_inicio_mandato"));setDate(ps,5,b.get("data_fim_mandato"));ps.executeUpdate();} if(Set.of("sindico","subsindico","conselheiro").contains(tipo))vincularContatoAosAvisosRecorrentes(c,cond,contato); audit(c,fid,"create","contato_condominio",contato,null,b); listarContatos(ex,c,cond);}
private void vincularContatoAosAvisosRecorrentes(Connection c,long cond,long contato)throws SQLException{
List<Map<String,Object>> avisos=rows(c,"SELECT id FROM avisos WHERE condominio_id=? AND recorrencia_ativa=true AND recorrencia_meses>0 AND (enviar_whatsapp=true OR enviar_email=true)",Arrays.asList(cond));
for(Map<String,Object>a:avisos){
long aviso=((Number)a.get("id")).longValue();
try(PreparedStatement ps=c.prepareStatement("INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,aviso);ps.setString(2,"contato");ps.setLong(3,contato);ps.executeUpdate();}
enfileirarAviso(c,aviso,Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
}
}
private void listarContatos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("contatos",rows(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.status='ativo' ORDER BY cc.tipo,c.nome",Arrays.asList(cond))));}
private void desvincularContato(HttpExchange ex,Connection c,long cond,long contato,long fid)throws Exception{String tipo=query(ex).get("tipo"); try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND contato_id=? AND tipo=?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.setString(3,tipo);ps.executeUpdate();} audit(c,fid,"delete","contato_condominio",contato,null,Map.of("tipo",tipo)); HttpResponses.json(ex,200,Map.of("ok",true));}
private void vincularFuncionario(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long func=Long.parseLong(req(b,"funcionario_id")); try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT 1 FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(!rs.next()){HttpResponses.json(ex,400,Map.of("erro","funcionario_invalido"));return;}}} execWrite(ex,c,"INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(func,cond),fid,"funcionario_condominio",func);}
private void listarFuncionariosVinculados(HttpExchange ex,Connection c,long emp,long cond)throws Exception{List<Map<String,Object>> links=rows(c,"SELECT * FROM funcionario_condominio WHERE condominio_id=? ORDER BY funcionario_id",Arrays.asList(cond));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> link:links){long func=((Number)link.get("funcionario_id")).longValue();ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next()){Map<String,Object> item=resultMap(rs);item.put("funcionario_id",func);item.put("condominio_id",cond);out.add(item);}}}}HttpResponses.json(ex,200,Map.of("funcionarios",out));}
private void criarEspaco(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO espacos_comuns (condominio_id,nome,descricao,valor_utilizacao_centavos,indice_correcao) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"nome"),nullable(b.get("descricao")),optLong(b.get("valor_utilizacao_centavos")),nullable(b.get("indice_correcao")))); audit(c,fid,"create","espacos_comuns",id,null,b); HttpResponses.json(ex,201,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(id))));}
private void atualizarEspaco(HttpExchange ex,Connection c,long cond,long eid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM espacos_comuns WHERE id=? AND condominio_id=?",Arrays.asList(eid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","espaco_nao_encontrado"));return;} Long novo=optLong(b.get("valor_utilizacao_centavos")); try(PreparedStatement ps=c.prepareStatement("UPDATE espacos_comuns SET nome=COALESCE(?,nome), descricao=COALESCE(?,descricao), valor_utilizacao_centavos=COALESCE(?,valor_utilizacao_centavos), indice_correcao=COALESCE(?,indice_correcao), data_ultimo_reajuste=CASE WHEN ? IS NOT NULL AND ?<>valor_utilizacao_centavos THEN current_date ELSE data_ultimo_reajuste END WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("descricao")); if(novo==null)ps.setNull(3,Types.BIGINT);else ps.setLong(3,novo); ps.setString(4,b.get("indice_correcao")); if(novo==null){ps.setNull(5,Types.BIGINT);ps.setNull(6,Types.BIGINT);}else{ps.setLong(5,novo);ps.setLong(6,novo);} ps.setLong(7,eid);ps.setLong(8,cond);ps.executeUpdate();} audit(c,fid,"update","espacos_comuns",eid,old,b); HttpResponses.json(ex,200,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(eid))));}
private void criarAssembleia(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{
Map<String,String>b=body(ex);
long id=insert(c,"INSERT INTO assembleias (condominio_id,tipo,data_hora,local,pauta) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"tipo"),ts(req(b,"data_hora")),nullable(b.get("local")),nullable(b.get("pauta"))));
audit(c,fid,"create","assembleias",id,null,b);
Map<String,Object> envio=criarAvisoAssembleia(c,emp,cond,id,fid,b);
HttpResponses.json(ex,201,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(id)),"envio",envio));
}
private Map<String,Object> criarAvisoAssembleia(Connection c,long emp,long cond,long assembleia,long fid,Map<String,String>b)throws Exception{
List<Map<String,Object>> contatosAviso=rows(c,"SELECT contato_id FROM contato_condominio WHERE condominio_id=? AND status=?",Arrays.asList(cond,"ativo"));
List<Map<String,Object>> funcionariosAviso=rows(c,"SELECT funcionario_id FROM funcionario_condominio WHERE condominio_id=?",Arrays.asList(cond));
int destinatarios=contatosAviso.size()+funcionariosAviso.size();
if(destinatarios==0)return Map.of("enviados",0,"falhas",0,"mensagem","Nenhum destinatario vinculado ao condominio.");
if(!funcionalidadeAtiva(emp,"whatsapp_envio"))return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao liberado para esta empresa.");
if(evolutionAprovada(emp)==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
String titulo="Assembleia " + req(b,"tipo");
StringBuilder mensagem=new StringBuilder();
mensagem.append("Nova assembleia agendada.");
mensagem.append("\nData/hora: ").append(req(b,"data_hora").replace('T',' '));
if(b.get("local")!=null&&!b.get("local").isBlank())mensagem.append("\nLocal: ").append(b.get("local"));
if(b.get("pauta")!=null&&!b.get("pauta").isBlank())mensagem.append("\nPauta: ").append(b.get("pauta"));
long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario) VALUES (?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,assembleia,"assembleia",titulo,mensagem.toString(),ts(b.get("data_hora")),"normal",true,false,"nao_enviado",fid));
for(Map<String,Object> row:contatosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"contato",((Number)row.get("contato_id")).longValue()));
for(Map<String,Object> row:funcionariosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"funcionario",((Number)row.get("funcionario_id")).longValue()));
enfileirarAviso(c,aviso,ts(b.get("data_hora")));
return Map.of("aviso_id",aviso,"destinatarios",destinatarios,"status","enfileirado");
}
private void atualizarAssembleia(HttpExchange ex,Connection c,long cond,long aid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM assembleias WHERE id=? AND condominio_id=?",Arrays.asList(aid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","assembleia_nao_encontrada"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE assembleias SET tipo=COALESCE(?,tipo), data_hora=COALESCE(?,data_hora), local=COALESCE(?,local), pauta=COALESCE(?,pauta), status=COALESCE(?,status) WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("tipo")); if(b.get("data_hora")==null)ps.setNull(2,Types.TIMESTAMP);else ps.setTimestamp(2,ts(b.get("data_hora"))); ps.setString(3,b.get("local"));ps.setString(4,b.get("pauta"));ps.setString(5,b.get("status"));ps.setLong(6,aid);ps.setLong(7,cond);ps.executeUpdate();} audit(c,fid,"update","assembleias",aid,old,b); HttpResponses.json(ex,200,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(aid))));}
private void uploadDocumento(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Multipart mp=parseMultipart(ex); if(mp.bytes.length>MAX_UPLOAD){HttpResponses.json(ex,400,Map.of("erro","arquivo_muito_grande"));return;} String ext=ext(mp.filename); if(!EXT_OK.contains(ext)){HttpResponses.json(ex,400,Map.of("erro","extensao_nao_permitida"));return;} String db=databaseName(emp); Path dir=uploadRoot.resolve(db).resolve(String.valueOf(cond)); Files.createDirectories(dir); String safe=UUID.randomUUID()+"-"+mp.filename.replaceAll("[^a-zA-Z0-9._-]","_"); Path file=dir.resolve(safe); Files.write(file,mp.bytes); String rel=db+"/"+cond+"/"+safe; long id=insert(c,"INSERT INTO documentos (condominio_id,tipo,nome_arquivo,caminho_armazenamento,enviado_por) VALUES (?,?,?,?,?)",Arrays.asList(cond,nullable(mp.tipo),mp.filename,rel,fid)); audit(c,fid,"create","documentos",id,null,Map.of("nome_arquivo",mp.filename)); HttpResponses.json(ex,201,Map.of("documento",one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id))));}
private void downloadDocumento(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object>d=one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id)); if(d==null){HttpResponses.json(ex,404,Map.of("erro","documento_nao_encontrado"));return;} Path f=uploadRoot.resolve(String.valueOf(d.get("caminho_armazenamento"))); if(!Files.exists(f)){HttpResponses.json(ex,404,Map.of("erro","arquivo_nao_encontrado"));return;} ex.getResponseHeaders().set("Content-Disposition","attachment; filename=\""+d.get("nome_arquivo")+"\""); ex.sendResponseHeaders(200,Files.size(f)); Files.copy(f,ex.getResponseBody()); ex.close();}
private void listarExtras(HttpExchange ex,Connection c)throws Exception{HttpResponses.json(ex,200,Map.of("extras",rows(c,"SELECT * FROM destinatarios_extra WHERE status<>'excluido' ORDER BY nome",Arrays.asList())));}
private void criarExtra(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex);long id=insert(c,"INSERT INTO destinatarios_extra (nome,email,telefone,status) VALUES (?,?,?,'ativo')",Arrays.asList(req(b,"nome"),nullable(b.get("email")),nullable(b.get("telefone"))));audit(c,fid,"create","destinatarios_extra",id,null,b);HttpResponses.json(ex,201,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
private void atualizarExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}Map<String,String>b=body(ex);try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("email"));ps.setString(3,b.get("telefone"));ps.setString(4,b.get("status"));ps.setLong(5,id);ps.executeUpdate();}audit(c,fid,"update","destinatarios_extra",id,old,b);HttpResponses.json(ex,200,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
private void excluirExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET status='excluido', atualizado_em=now() WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","destinatarios_extra",id,old,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
private void listarPessoasLembrete(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE empresa_id=? AND status='ativo' ORDER BY nome")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("pessoas",out));}}
private void criarLembrete(HttpExchange ex,Connection c,long emp,long fid)throws Exception{
String raw=rawBody(ex);
Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));
List<Long>dests=longArray(raw,"destinatarios");
Timestamp dataHora=ts(b.get("data_hora"));
boolean enviarAgora=dataHora==null||!dataHora.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
if(enviarAgora&&!dests.isEmpty()&&evolutionAprovada(emp)==null){
HttpResponses.json(ex,400,Map.of("erro","instancia_whatsapp_indisponivel","mensagem","Configure, conecte e aprove o WhatsApp antes de enviar lembretes."));
return;
}
long id;
c.setAutoCommit(false);
try{
id=insert(c,"INSERT INTO lembretes (titulo,mensagem,data_hora,status,criado_por_funcionario) VALUES (?,?,?,?,?)",Arrays.asList(req(b,"titulo"),req(b,"mensagem"),dataHora,b.getOrDefault("status","ativo"),fid));
salvarDestinatariosLembrete(c,id,dests);
if(!dests.isEmpty()&&"ativo".equalsIgnoreCase(b.getOrDefault("status","ativo")))enfileirarLembrete(c,id,dataHora);
audit(c,fid,"create","lembretes",id,null,b);
c.commit();
}catch(Exception err){
c.rollback();
throw err;
}finally{
c.setAutoCommit(true);
}
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
HttpResponses.json(ex,201,Map.of("lembrete",lembrete,"envio",Map.of("enfileirado",!dests.isEmpty(),"agendado",!enviarAgora)));
}
private void listarLembretes(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{List<Map<String,Object>> base=rows(c,"SELECT l.*, CASE WHEN l.criado_por_funcionario=? THEN true ELSE false END AS criado_por_mim FROM lembretes l ORDER BY COALESCE(l.data_hora,l.criado_em) DESC,l.id DESC",Arrays.asList(fid));for(Map<String,Object> l:base){long id=((Number)l.get("id")).longValue();boolean podeAlterar=adminEmpresa(cargo)||Boolean.TRUE.equals(l.get("criado_por_mim"));String status=str(l.get("status"));l.put("pode_alterar",podeAlterar);l.put("pode_editar",podeAlterar&&!"concluido".equalsIgnoreCase(status)&&!"enviado".equalsIgnoreCase(status));l.put("destinatarios",destinatariosLembrete(c,emp,id));}HttpResponses.json(ex,200,Map.of("lembretes",base));}
private void enviarLembreteManual(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
if(lembrete==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}
if(!podeAlterarLembrete(lembrete,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode enviar."));return;}
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
if(dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o lembrete."));return;}
Map<String,Object> envio=enviarLembreteWhatsApp(emp,id,lembrete);
if(Number.class.isInstance(envio.get("falhas"))&&((Number)envio.get("falhas")).intValue()==0&&Number.class.isInstance(envio.get("enviados"))&&((Number)envio.get("enviados")).intValue()>0){finalizarOuReagendarLembrete(c,id);}
HttpResponses.json(ex,200,Map.of("envio",envio));
}
private void atualizarLembrete(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode alterar."));return;}String statusAtual=str(old.get("status"));if("concluido".equalsIgnoreCase(statusAtual)||"enviado".equalsIgnoreCase(statusAtual)){HttpResponses.json(ex,409,Map.of("erro","lembrete_ja_enviado","mensagem","Este lembrete ja foi enviado e nao pode mais ser editado."));return;}String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));List<Long>dests=longArray(raw,"destinatarios");c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET titulo=COALESCE(?,titulo), mensagem=COALESCE(?,mensagem), data_hora=COALESCE(?,data_hora), status=COALESCE(?,status), recorrencia_meses=?, recorrencia_ativa=?, atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("titulo"));ps.setString(2,b.get("mensagem"));if(b.containsKey("data_hora")){Timestamp t=ts(b.get("data_hora"));if(t==null)ps.setNull(3,Types.TIMESTAMP);else ps.setTimestamp(3,t);}else ps.setNull(3,Types.TIMESTAMP);ps.setString(4,b.get("status"));int rec=recorrenciaMeses(b.get("recorrencia_meses"));ps.setInt(5,rec);ps.setBoolean(6,rec>0);ps.setLong(7,id);ps.executeUpdate();}if(raw.contains("\"destinatarios\""))salvarDestinatariosLembrete(c,id,dests);Map<String,Object> atual=one(c,"SELECT status,data_hora FROM lembretes WHERE id=?",Arrays.asList(id));if(atual!=null&&"ativo".equalsIgnoreCase(str(atual.get("status"))))enfileirarLembrete(c,id,(Timestamp)atual.get("data_hora"));audit(c,fid,"update","lembretes",id,old,b);c.commit();HttpResponses.json(ex,200,Map.of("lembrete",lembreteComDetalhes(c,emp,id)));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void excluirLembrete(HttpExchange ex,Connection c,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode excluir."));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembretes WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","lembretes",id,old,Map.of("ok",true));c.commit();HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private boolean podeAlterarLembrete(Map<String,Object> lembrete,String cargo,long fid){return adminEmpresa(cargo)||((Number)lembrete.get("criado_por_funcionario")).longValue()==fid;}
private Map<String,Object> enviarLembreteWhatsApp(long emp,long lembreteId,Map<String,Object> lembrete)throws Exception{
Map<String,Object> inst=evolutionAprovada(emp);
if(inst==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);
String texto="Lembrete: "+str(lembrete.get("titulo"))+"\n\n"+str(lembrete.get("mensagem"));
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
int enviados=0, falhas=0;
List<String> detalhes=new ArrayList<>();
for(Map<String,Object> dest:dests){
long did=((Number)dest.get("id")).longValue();
String nome=str(dest.get("nome"));
String tel=telefoneWhatsapp(dest.get("telefone"));
try{
if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");
String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,texto);
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","enviado",limite(resposta));
enviados++;
}catch(Exception err){
falhas++;
detalhes.add(nome+": "+err.getMessage());
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","falhou",limite(err.getMessage()));
}
}
Map<String,Object> out=new LinkedHashMap<>();
out.put("enviados",enviados);
out.put("falhas",falhas);
if(!detalhes.isEmpty())out.put("detalhes",detalhes);
return out;
}
private void salvarDestinatariosLembrete(Connection c,long lembrete,List<Long>dests)throws SQLException{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembrete_destinatarios WHERE lembrete_id=?")){ps.setLong(1,lembrete);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO lembrete_destinatarios (lembrete_id,funcionario_id) VALUES (?,?) ON CONFLICT DO NOTHING")){for(Long d:dests){ps.setLong(1,lembrete);ps.setLong(2,d);ps.addBatch();}ps.executeBatch();}}
private Map<String,Object> lembreteComDetalhes(Connection c,long emp,long id)throws Exception{Map<String,Object> l=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(l!=null&&emp>0)l.put("destinatarios",destinatariosLembrete(c,emp,id));return l;}
private List<Map<String,Object>> destinatariosLembrete(Connection c,long emp,long id)throws Exception{List<Map<String,Object>> ids=rows(c,"SELECT funcionario_id FROM lembrete_destinatarios WHERE lembrete_id=? ORDER BY funcionario_id",Arrays.asList(id));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> row:ids){ps.setLong(1,((Number)row.get("funcionario_id")).longValue());ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next())out.add(resultMap(rs));}}}return out;}
private void listarFuncionariosEmpresa(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE empresa_id=? ORDER BY id")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("funcionarios",out));}}
private void criarFuncionarioEmpresa(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);try(Connection m=masterDs.getMasterConnection()){if(one(m,"SELECT id FROM funcionarios WHERE email=?",Arrays.asList(req(b,"email")))!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}try(PreparedStatement ps=m.prepareStatement("INSERT INTO funcionarios (empresa_id,nome,email,telefone,senha_hash,cargo,status) VALUES (?,?,?,?,?,?,'ativo') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,req(b,"nome"));ps.setString(3,req(b,"email"));ps.setString(4,b.get("telefone"));ps.setString(5,com.seconectai.condocontab.auth.PasswordHasher.hash(req(b,"senha")));ps.setString(6,b.getOrDefault("cargo","funcionario"));try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"ok",true));}}}}
private void atualizarFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
Map<String,String>b=body(ex);
try(Connection m=masterDs.getMasterConnection()){
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
if("admin_empresa".equals(str(atual.get("cargo")))&&funcId!=solicitante){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser alterada por aqui."));return;}
String email=b.get("email");
if(email!=null&&!email.isBlank()){
Map<String,Object> dono=one(m,"SELECT id FROM funcionarios WHERE email=? AND id<>?",Arrays.asList(email,funcId));
if(dono!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}
}
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), senha_hash=COALESCE(?,senha_hash) WHERE id=? AND empresa_id=?")){
ps.setString(1,b.get("nome"));
ps.setString(2,email);
ps.setString(3,b.get("telefone"));
ps.setString(4,b.get("status"));
String senha=b.get("senha");
ps.setString(5,senha==null||senha.isBlank()?null:com.seconectai.condocontab.auth.PasswordHasher.hash(senha));
ps.setLong(6,funcId);
ps.setLong(7,empresaId);
ps.executeUpdate();
}
HttpResponses.json(ex,200,Map.of("funcionario",one(m,"SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE id=?",Arrays.asList(funcId))));
}
}
private void excluirFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
if(funcId==solicitante){HttpResponses.json(ex,400,Map.of("erro","auto_exclusao_bloqueada","mensagem","Voce nao pode excluir seu proprio acesso."));return;}
try(Connection m=masterDs.getMasterConnection()){
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
if("admin_empresa".equals(str(atual.get("cargo")))){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser excluida."));return;}
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET status='inativo' WHERE id=? AND empresa_id=?")){ps.setLong(1,funcId);ps.setLong(2,empresaId);ps.executeUpdate();}
}
try(Connection c=tenantDs.getConnection(empresaId);PreparedStatement ps=c.prepareStatement("DELETE FROM funcionario_condominio WHERE funcionario_id=?")){
ps.setLong(1,funcId);
ps.executeUpdate();
}
HttpResponses.json(ex,200,Map.of("ok",true));
}
private boolean adminEmpresa(String cargo){return "admin_empresa".equals(cargo);}
private void conectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);String url=b.getOrDefault("instance_url","");String numero=EvolutionApiClient.onlyDigits(req(b,"numero_whatsapp"));if(numero.length()<10){HttpResponses.json(ex,400,Map.of("erro","numero_whatsapp_invalido","mensagem","Informe o numero com DDD e codigo do pais."));return;}try(Connection m=masterDs.getMasterConnection()){liberarCadastrosEvolutionOrfaos(m,numero);Map<String,Object> existente=one(m,"SELECT id,empresa_id,status FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id DESC LIMIT 1",Arrays.asList(numero));if(existente!=null){HttpResponses.json(ex,409,Map.of("erro","numero_whatsapp_ja_cadastrado","mensagem","Este numero de WhatsApp ja esta cadastrado em uma conexao."));return;}}String instance="empresa"+empresaId+"_"+System.currentTimeMillis();String token=UUID.randomUUID().toString().replace("-","");var criada=evolutionApiClient.criarInstancia(url,instance,token,numero);try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO evolution_instancias (empresa_id,instance_url,instance_token_enc,numero_whatsapp,status) VALUES (?,?,?,?, 'pendente') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,url);ps.setString(3,criada.instanceName()+":"+criada.token());ps.setString(4,numero);try(ResultSet rs=ps.executeQuery()){rs.next();Map<String,Object> resp=new LinkedHashMap<>();resp.put("id",rs.getLong(1));resp.put("instance_name",criada.instanceName());resp.put("status","pendente");resp.put("qrcode_base64",criada.qrcodeBase64());resp.put("mock",criada.mock());HttpResponses.json(ex,201,resp);}}}
private void liberarCadastrosEvolutionOrfaos(Connection m,String numero)throws Exception{List<Map<String,Object>> existentes=rows(m,"SELECT id,instance_url,instance_token_enc FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id",Arrays.asList(numero));for(Map<String,Object> inst:existentes){String token=str(inst.get("instance_token_enc"));String instanceName=token.split(":",2)[0];if(instanceName.isBlank())continue;try{evolutionApiClient.estado(str(inst.get("instance_url")),instanceName);}catch(Exception err){if(!evolutionNaoEncontrada(err))throw err;try(PreparedStatement ps=m.prepareStatement("DELETE FROM evolution_instancias WHERE id=?")){ps.setLong(1,((Number)inst.get("id")).longValue());ps.executeUpdate();}}}}
private boolean evolutionNaoEncontrada(Exception err){String msg=err.getMessage()==null?"":err.getMessage().toLowerCase(Locale.ROOT);return msg.contains("404")||msg.contains("not found")||msg.contains("instance not found")||msg.contains("nao encontrada")||msg.contains("não encontrada");}
private void statusEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,200,Map.of("status","nao_configurada"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("instancia",inst);try{String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);resp.put("evolution",evolutionApiClient.estado(str(inst.get("instance_url")),parts[0]));}catch(Exception ignored){}HttpResponses.json(ex,200,resp);}
private void qrcodeEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);Map<String,Object> qr=evolutionApiClient.conectar(str(inst.get("instance_url")),parts[0],str(inst.get("numero_whatsapp")));HttpResponses.json(ex,200,qr);}
private void desconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.logout(str(inst.get("instance_url")),parts[0]));}
private void reconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.reiniciar(str(inst.get("instance_url")),parts[0]));}
private Map<String,Object> evolutionInstancia(long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC,id DESC LIMIT 1")){ps.setLong(1,empresaId);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private void listarAssembleiasCalendario(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{
String sql=adminEmpresa(cargo)?"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id ORDER BY a.data_hora DESC":"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id JOIN funcionario_condominio fc ON fc.condominio_id=a.condominio_id AND fc.funcionario_id=? ORDER BY a.data_hora DESC";
List<Map<String,Object>> out=rows(c,sql,adminEmpresa(cargo)?Arrays.asList():Arrays.asList(fid));
for(Map<String,Object>a:out){
long aid=((Number)a.get("id")).longValue();
Map<String,Object> aviso=one(c,"SELECT id FROM avisos WHERE assembleia_id=? ORDER BY id DESC LIMIT 1",Arrays.asList(aid));
if(aviso!=null){long avisoId=((Number)aviso.get("id")).longValue();a.put("aviso_id",avisoId);a.put("destinatarios",destinatariosAviso(c,emp,avisoId));}
else a.put("destinatarios",List.of());
}
HttpResponses.json(ex,200,Map.of("assembleias",out));
}
private List<Map<String,Object>> destinatariosAviso(Connection c,long emp,long aviso)throws Exception{
List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso));
List<Map<String,Object>> out=new ArrayList<>();
for(Map<String,Object>d:dests){
String tipo=str(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> pessoa;
if("funcionario".equals(tipo))pessoa=funcionario(emp,did);else if("extra".equals(tipo))pessoa=one(c,"SELECT id,nome,email,telefone FROM destinatarios_extra WHERE id=?",Arrays.asList(did));else pessoa=one(c,"SELECT id,nome,email,telefone FROM contatos WHERE id=?",Arrays.asList(did));
if(pessoa!=null){Map<String,Object> item=new LinkedHashMap<>(pessoa);item.put("tipo",tipo);out.add(item);}
}
return out;
}
private void criarAviso(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(removeArrayField(raw,"documentos"),"destinatarios"));boolean w=bool(b.get("enviar_whatsapp"));boolean e=bool(b.get("enviar_email"));List<Destinatario> dests=destinatarios(raw);if((w||e)&&dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o aviso."));return;}Timestamp dataEnvio=ts(b.get("data_evento"));boolean agendado=dataEnvio!=null&&dataEnvio.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));if(w&&!funcionalidadeAtiva(emp,"whatsapp_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_whatsapp_inativa","mensagem","Envio por WhatsApp nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque WhatsApp."));return;}if(e&&!funcionalidadeAtiva(emp,"email_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_email_inativa","mensagem","Envio por e-mail nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque E-mail."));return;}if(w&&evolutionAprovada(emp)==null){HttpResponses.json(ex,400,Map.of("erro","instancia_pendente_aprovacao","mensagem","A conexao WhatsApp ainda nao foi aprovada no painel Master."));return;}c.setAutoCommit(false);try{int rec=recorrenciaMeses(b.get("recorrencia_meses"));long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,data_expiracao,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario,recorrencia_meses,recorrencia_ativa) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,optLong(b.get("assembleia_id")),b.getOrDefault("tipo","aviso"),req(b,"titulo"),req(b,"mensagem"),dataEnvio,ts(b.get("data_expiracao")),b.getOrDefault("prioridade","normal"),w,e,agendado?"agendado":"nao_enviado",fid,rec,rec>0));for(Long d:longArray(raw,"documentos")){insert(c,"INSERT INTO aviso_documentos (aviso_id,documento_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d));}for(Destinatario d:dests){insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d.tipo(),d.id()));}audit(c,fid,"create","avisos",aviso,null,b);if(w||e)enfileirarAviso(c,aviso,dataEnvio);c.commit();detalheAviso(ex,c,cond,aviso);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void listarAvisos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("avisos",rows(c,"SELECT * FROM avisos WHERE condominio_id=? ORDER BY data_publicacao DESC,id DESC",Arrays.asList(cond))));}
private void detalheAviso(HttpExchange ex,Connection c,long cond,long aviso)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("aviso",a);resp.put("documentos",rows(c,"SELECT ad.*, d.nome_arquivo, d.caminho_armazenamento FROM aviso_documentos ad JOIN documentos d ON d.id=ad.documento_id WHERE ad.aviso_id=?",Arrays.asList(aviso)));resp.put("destinatarios",rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso)));HttpResponses.json(ex,200,resp);}
private void reenviarAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso)throws Exception{try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='nao_enviado' WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);if(ps.executeUpdate()==0){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}}enviarAviso(c,emp,cond,aviso,false);detalheAviso(ex,c,cond,aviso);}
private void excluirAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(old==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_documentos WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_destinatarios WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM avisos WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);ps.executeUpdate();}audit(c,fid,"delete","avisos",aviso,old,Map.of("ok",true));c.commit();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("DELETE FROM logs_envio_avisos WHERE empresa_id=? AND condominio_id=? AND aviso_id=?")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void enviarAviso(Connection c,long emp,long cond,long aviso,boolean apenasFalhos)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null)return;boolean w=Boolean.TRUE.equals(a.get("enviar_whatsapp"));boolean e=Boolean.TRUE.equals(a.get("enviar_email"));Map<String,Object> inst=w?evolutionAprovada(emp):null;Map<String,String> smtp=e?smtpConfig():Map.of();List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(aviso));boolean allOk=true;int tentativas=0;if(dests.isEmpty())allOk=false;for(Map<String,Object>d:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> contato="funcionario".equals(tipo)?funcionario(emp,did):("extra".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM destinatarios_extra WHERE id=? AND status='ativo'",Arrays.asList(did)):one(c,"SELECT id,nome,telefone,email FROM contatos WHERE id=?",Arrays.asList(did)));if(contato==null){allOk=false;continue;}String nome=String.valueOf(contato.get("nome"));if(w&&deveTentar(emp,aviso,did,"whatsapp",apenasFalhos)){tentativas++;String tel=telefoneWhatsapp(contato.get("telefone"));try{if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,msg(a));logEnvio(emp,cond,aviso,did,nome,"whatsapp","enviado",limite(resposta));}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"whatsapp","falhou",err.getMessage());}}if(e&&deveTentar(emp,aviso,did,"email",apenasFalhos)){tentativas++;String email=str(contato.get("email"));try{if(!email.contains("@"))throw new IllegalArgumentException("email_invalido");emailClient.enviar(smtp,email,str(a.get("titulo")),msg(a));logEnvio(emp,cond,aviso,did,nome,"email","enviado",null);}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"email","falhou",err.getMessage());}}}if(allOk&&tentativas>0&&reagendarAvisoRecorrente(c,aviso,a))return;try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio=?, ultimo_envio=CASE WHEN ? THEN now() ELSE ultimo_envio END WHERE id=?")){boolean ok=allOk&&tentativas>0;ps.setString(1,ok?"enviado":"falhou");ps.setBoolean(2,ok);ps.setLong(3,aviso);ps.executeUpdate();}}
private boolean reagendarAvisoRecorrente(Connection c,long aviso,Map<String,Object>a)throws SQLException{
int meses=a.get("recorrencia_meses")==null?0:((Number)a.get("recorrencia_meses")).intValue();
if(!Boolean.TRUE.equals(a.get("recorrencia_ativa"))||meses<=0)return false;
Timestamp base=(Timestamp)a.get("data_evento");
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
LocalDateTime proximo=inicio.plusMonths(meses);
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
Timestamp prox=Timestamp.valueOf(proximo);
try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='agendado', data_evento=?, ultimo_envio=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,aviso);ps.executeUpdate();}
enfileirarAviso(c,aviso,prox);
return true;
}
private boolean avisoRecorrente(Connection c,long aviso)throws SQLException{Map<String,Object>a=one(c,"SELECT recorrencia_ativa,recorrencia_meses FROM avisos WHERE id=?",Arrays.asList(aviso));return a!=null&&Boolean.TRUE.equals(a.get("recorrencia_ativa"))&&a.get("recorrencia_meses")!=null&&((Number)a.get("recorrencia_meses")).intValue()>0;}
private boolean deveTentar(long emp,long aviso,long contato,String canal,boolean apenasFalhos)throws SQLException{if(!apenasFalhos)return true;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT status FROM logs_envio_avisos WHERE empresa_id=? AND aviso_id=? AND contato_id=? AND canal=? ORDER BY criado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);ps.setLong(2,aviso);ps.setLong(3,contato);ps.setString(4,canal);try(ResultSet rs=ps.executeQuery()){return rs.next()&&"falhou".equals(rs.getString(1));}}}
private void logEnvio(long emp,long cond,long aviso,long contato,String nome,String canal,String status,String erro)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO logs_envio_avisos (empresa_id,condominio_id,aviso_id,contato_id,contato_nome,canal,status,erro_detalhe) VALUES (?,?,?,?,?,?,?,?)")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.setLong(4,contato);ps.setString(5,nome);ps.setString(6,canal);ps.setString(7,status);ps.setString(8,erro);ps.executeUpdate();}}
private boolean funcionalidadeAtiva(long emp,String chave)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT COALESCE(ef.ativa,false) FROM funcionalidades f LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=? WHERE f.chave=?")){ps.setLong(1,emp);ps.setString(2,chave);try(ResultSet rs=ps.executeQuery()){return rs.next()&&rs.getBoolean(1);}}}
private Map<String,Object> evolutionAprovada(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT * FROM evolution_instancias WHERE empresa_id=? AND status='aprovada' ORDER BY aprovado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private Map<String,String> smtpConfig()throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT chave,valor FROM configuracoes_plataforma WHERE chave LIKE 'smtp_%'")){Map<String,String> out=new HashMap<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.put(rs.getString(1),rs.getString(2));}return out;}}
private Map<String,Object> funcionario(long emp,long id)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,id);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private String telefoneWhatsapp(Object raw){String d=EvolutionApiClient.onlyDigits(str(raw));if((d.length()==10||d.length()==11)&&!d.startsWith("55"))return "55"+d;return d;}
private String limite(String v){if(v==null)return null;return v.length()>900?v.substring(0,900):v;}
private String msg(Map<String,Object>a){return str(a.get("titulo"))+"\n\n"+str(a.get("mensagem"));}
private void concluirFilaLembrete(Connection c,long lembreteId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=\'enviado\', locked_at=NULL, atualizado_em=now() WHERE tipo=\'lembrete\' AND referencia_id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}}
private void enfileirarAviso(Connection c,long avisoId,Timestamp dataHora)throws SQLException{
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('aviso',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
ps.setLong(1,avisoId);
ps.setTimestamp(2,agendado);
ps.executeUpdate();
}
}
private void enfileirarLembrete(Connection c,long lembreteId,Timestamp dataHora)throws SQLException{
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('lembrete',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
ps.setLong(1,lembreteId);
ps.setTimestamp(2,agendado);
ps.executeUpdate();
}
}
public int processarFilaEnvios()throws Exception{
int total=0;
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
try(ResultSet rs=ps.executeQuery()){
while(rs.next()){
long emp=rs.getLong(1);
try(Connection c=tenantDs.getConnection(emp)){
total+=processarFilaEmpresa(c,emp);
}catch(Exception err){
System.err.println("Falha ao processar fila de envio da empresa "+emp+": "+err.getMessage());
}
}
}
}
return total;
}
private int processarFilaEmpresa(Connection c,long emp)throws Exception{
int total=0;
while(true){
Map<String,Object> item=proximoItemFila(c);
if(item==null)return total;
long filaId=((Number)item.get("id")).longValue();
long ref=((Number)item.get("referencia_id")).longValue();
try{
String tipo=str(item.get("tipo"));
if("lembrete".equals(tipo)){
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,ref);
if(lembrete==null||!"ativo".equalsIgnoreCase(str(lembrete.get("status")))){
concluirItemFila(c,filaId,"ignorado",null);
}else{
Map<String,Object> envio=enviarLembreteWhatsApp(emp,ref,lembrete);
int enviados=Number.class.isInstance(envio.get("enviados"))?((Number)envio.get("enviados")).intValue():0;
int falhas=Number.class.isInstance(envio.get("falhas"))?((Number)envio.get("falhas")).intValue():1;
if(enviados>0&&falhas==0){
boolean reagendado=finalizarOuReagendarLembrete(c,ref);
if(!reagendado)concluirItemFila(c,filaId,"enviado",null);
}else{
falharItemFila(c,filaId,str(envio.getOrDefault("mensagem","Falha ao enviar lembrete.")));
}
}
}else if("aviso".equals(tipo)){
Map<String,Object> aviso=one(c,"SELECT id,condominio_id,status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
if(aviso==null||"enviado".equalsIgnoreCase(str(aviso.get("status_envio")))){
concluirItemFila(c,filaId,"ignorado",null);
}else{
long cond=((Number)aviso.get("condominio_id")).longValue();
enviarAviso(c,emp,cond,ref,false);
Map<String,Object> atualizado=one(c,"SELECT status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
if(atualizado!=null&&"enviado".equalsIgnoreCase(str(atualizado.get("status_envio"))))concluirItemFila(c,filaId,"enviado",null);
else if(atualizado!=null&&"agendado".equalsIgnoreCase(str(atualizado.get("status_envio")))&&avisoRecorrente(c,ref)){ }
else falharItemFila(c,filaId,"Falha ao enviar aviso.");
}
}else{
concluirItemFila(c,filaId,"ignorado","Tipo de mensagem desconhecido: "+tipo);
}
total++;
}catch(Exception err){
falharItemFila(c,filaId,err.getMessage());
total++;
}
}
}
private Map<String,Object> proximoItemFila(Connection c)throws SQLException{
c.setAutoCommit(false);
try{
Map<String,Object> item=one(c,"SELECT * FROM fila_envio WHERE status='pendente' AND agendado_para <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY agendado_para,id LIMIT 1 FOR UPDATE SKIP LOCKED",Arrays.asList());
if(item!=null){
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status='processando', locked_at=now(), tentativas=tentativas+1, atualizado_em=now() WHERE id=?")){
ps.setLong(1,((Number)item.get("id")).longValue());
ps.executeUpdate();
}
}
c.commit();
return item;
}catch(Exception err){
c.rollback();
throw err;
}finally{
c.setAutoCommit(true);
}
}
private void concluirItemFila(Connection c,long filaId,String status,String erro)throws SQLException{
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=?, ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
ps.setString(1,status);
ps.setString(2,erro);
ps.setLong(3,filaId);
ps.executeUpdate();
}
}
private void falharItemFila(Connection c,long filaId,String erro)throws SQLException{
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=CASE WHEN tentativas>=3 THEN 'falhou' ELSE 'pendente' END, agendado_para=(now() AT TIME ZONE 'America/Sao_Paulo') + interval '1 minute', ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
ps.setString(1,limite(erro));
ps.setLong(2,filaId);
ps.executeUpdate();
}
}
public int enviarLembretesAgendados()throws Exception{
int total=0;
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
try(ResultSet rs=ps.executeQuery()){
while(rs.next()){
long emp=rs.getLong(1);
try(Connection c=tenantDs.getConnection(emp)){
List<Map<String,Object>> pendentes=rows(c,"SELECT id FROM lembretes WHERE status='ativo' AND data_hora IS NOT NULL AND data_hora <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY data_hora,id",Arrays.asList());
for(Map<String,Object> row:pendentes){
long lembreteId=((Number)row.get("id")).longValue();
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,lembreteId);
if(lembrete==null)continue;
Map<String,Object> envio=enviarLembreteWhatsApp(emp,lembreteId,lembrete);
int enviados=((Number)envio.getOrDefault("enviados",0)).intValue();
int falhas=((Number)envio.getOrDefault("falhas",0)).intValue();
if(enviados>0&&falhas==0)finalizarOuReagendarLembrete(c,lembreteId);
total++;
}
}catch(Exception err){
System.err.println("Falha ao enviar lembretes agendados da empresa "+emp+": "+err.getMessage());
}
}
}
}
return total;
}
private boolean finalizarOuReagendarLembrete(Connection c,long lembreteId)throws SQLException{
Map<String,Object> l=one(c,"SELECT recorrencia_ativa,recorrencia_meses,data_hora FROM lembretes WHERE id=?",Arrays.asList(lembreteId));
int meses=l==null||l.get("recorrencia_meses")==null?0:((Number)l.get("recorrencia_meses")).intValue();
boolean recorrente=l!=null&&Boolean.TRUE.equals(l.get("recorrencia_ativa"))&&meses>0;
if(recorrente){
Timestamp base=(Timestamp)l.get("data_hora");
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
LocalDateTime proximo=inicio.plusMonths(meses);
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
Timestamp prox=Timestamp.valueOf(proximo);
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET data_hora=?, ultimo_envio=now(), status='ativo', atualizado_em=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,lembreteId);ps.executeUpdate();}
enfileirarLembrete(c,lembreteId,prox);
return true;
}
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET status='concluido', ultimo_envio=now(), atualizado_em=now() WHERE id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}
concluirFilaLembrete(c,lembreteId);
return false;
}
public int enviarAvisosAgendados()throws Exception{int total=0;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next()){long emp=rs.getLong(1);try(Connection c=tenantDs.getConnection(emp)){List<Map<String,Object>> pendentes=rows(c,"SELECT id,condominio_id FROM avisos WHERE (enviar_whatsapp=true OR enviar_email=true) AND status_envio IN ('nao_enviado','agendado') AND (data_evento IS NULL OR data_evento<=(now() AT TIME ZONE 'America/Sao_Paulo')) ORDER BY data_evento NULLS FIRST,id",Arrays.asList());for(Map<String,Object>a:pendentes){enviarAviso(c,emp,((Number)a.get("condominio_id")).longValue(),((Number)a.get("id")).longValue(),false);total++;}}catch(Exception err){System.err.println("Falha ao enviar avisos agendados da empresa "+emp+": "+err.getMessage());}}}}return total;}
private void execWrite(HttpExchange ex,Connection c,String sql,List<?>params,long fid,String tabela,long rid)throws Exception{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);ps.executeUpdate();}audit(c,fid,"create",tabela,rid,null,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
private void listar(HttpExchange ex,Connection c,String sql,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList())));}
private void listarParam(HttpExchange ex,Connection c,String sql,long id,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList(id))));}
private long insert(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql,Statement.RETURN_GENERATED_KEYS)){bind(ps,params);ps.executeUpdate();try(ResultSet rs=ps.getGeneratedKeys()){rs.next();return rs.getLong(1);}}}
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{List<Map<String,Object>> r=rows(c,sql,params);return r.isEmpty()?null:r.get(0);} private List<Map<String,Object>> rows(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){List<Map<String,Object>> out=new ArrayList<>();ResultSetMetaData md=rs.getMetaData();while(rs.next()){Map<String,Object> m=new LinkedHashMap<>();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));out.add(m);}return out;}}}
private long count(Connection c,String sql,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long count(Connection c,String sql,long a,long b)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,a);ps.setLong(2,b);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}
private void audit(Connection c,long fid,String acao,String tabela,long rid,Object old,Object neu)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO log_auditoria (funcionario_id,acao,tabela_afetada,registro_id,dados_anteriores,dados_novos) VALUES (?,?,?,?,?::jsonb,?::jsonb)")){ps.setLong(1,fid);ps.setString(2,acao);ps.setString(3,tabela);ps.setLong(4,rid);ps.setString(5,old==null?null:Json.write(old));ps.setString(6,neu==null?null:Json.write(neu));ps.executeUpdate();}}
private void bind(PreparedStatement ps,List<?>params)throws SQLException{for(int i=0;i<params.size();i++){Object v=params.get(i); if(v==null)ps.setObject(i+1,null); else ps.setObject(i+1,v);}}
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));} private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);} private String req(Map<String,String>m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;} private Object nullable(String v){return v==null||v.isBlank()?null:v;} private Long optLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);} private long id(String s){return Long.parseLong(s);} private void setDate(PreparedStatement ps,int i,String v)throws SQLException{if(v==null||v.isBlank())ps.setNull(i,Types.DATE);else ps.setDate(i,java.sql.Date.valueOf(LocalDate.parse(v)));}
private Timestamp ts(String v){
if(v==null||v.isBlank())return null;
String x=v.trim().replace('T',' ').replace(",", " ").replaceAll("\\s+", " ");
try{
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"))x=x+":00";
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"))return Timestamp.valueOf(x);
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"));return Timestamp.valueOf(dt);}
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"));return Timestamp.valueOf(dt);}
}catch(Exception ignored){}
throw new IllegalArgumentException("Data/hora invalida. Use dd/mm/aaaa hh:mm.");
} private int recorrenciaMeses(String v){if(v==null||v.isBlank())return 0;int n=Integer.parseInt(v);if(n==0||n==1||n==3||n==12)return n;throw new IllegalArgumentException("Recorrencia invalida. Use 0, 1, 3 ou 12 meses.");} private boolean bool(String v){return "true".equalsIgnoreCase(v)||"1".equals(v)||"sim".equalsIgnoreCase(v);} private String str(Object v){return v==null?"":String.valueOf(v);} private String cnpj(String v){String d=v==null?"":v.replaceAll("\\D","");if(d.length()!=14)throw new IllegalArgumentException("CNPJ invalido. Informe 14 digitos.");return d;}
private List<Long> longArray(String raw,String key){String a=arrayBody(raw,key);if(a.isBlank())return List.of();List<Long> out=new ArrayList<>();for(String x:a.split(",")){String v=x.trim().replace("\"","");if(!v.isBlank())out.add(Long.parseLong(v));}return out;}
private List<Destinatario> destinatarios(String raw){String a=arrayBody(raw,"destinatarios");if(a.isBlank())return List.of();List<Destinatario> out=new ArrayList<>();for(String obj:a.split("\\},\\s*\\{")){String x=obj.replace("{","").replace("}","");String tipo=null;Long id=null;for(String kv:x.split(",")){String[]p=kv.split(":",2);if(p.length<2)continue;String k=p[0].trim().replace("\"","");String v=p[1].trim().replace("\"","");if("tipo".equals(k)||"tipo_destinatario".equals(k))tipo=v;if("id".equals(k)||"destinatario_id".equals(k))id=Long.parseLong(v);}if(tipo!=null&&id!=null)out.add(new Destinatario(tipo,id));}return out;}
private String arrayBody(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return"";int s=raw.indexOf('[',p);if(s<0)return"";int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(s+1,i).trim();}return"";}
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int s=raw.indexOf('[',p);if(s<0)return raw;int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(0,s)+"\"\""+raw.substring(i+1);}return raw;} record Destinatario(String tipo,long id){}
private Map<String,String> query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String part:raw.split("&")){String[]kv=part.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
private String databaseName(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT database_name FROM empresas WHERE id=?")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getString(1);}}}
private String ext(String name){int p=name.lastIndexOf('.');return p<0?"":name.substring(p+1).toLowerCase();}
private Multipart parseMultipart(HttpExchange ex)throws IOException{String ct=ex.getRequestHeaders().getFirst("Content-Type");if(ct==null||!ct.contains("boundary="))throw new IllegalArgumentException("multipart/form-data esperado");String boundary="--"+ct.substring(ct.indexOf("boundary=")+9);byte[] body=ex.getRequestBody().readAllBytes();String text=new String(body,StandardCharsets.ISO_8859_1);String filename=null,tipo=null;byte[] fileBytes=null;for(String part:text.split(java.util.regex.Pattern.quote(boundary))){if(!part.contains("Content-Disposition"))continue;int hEnd=part.indexOf("\r\n\r\n");if(hEnd<0)continue;String headers=part.substring(0,hEnd);String content=part.substring(hEnd+4);if(content.endsWith("\r\n"))content=content.substring(0,content.length()-2);if(headers.contains("name=\"tipo\""))tipo=content.trim();if(headers.contains("name=\"arquivo\"")){int fp=headers.indexOf("filename=\"");if(fp>=0){int fe=headers.indexOf('"',fp+10);filename=headers.substring(fp+10,fe);}fileBytes=content.getBytes(StandardCharsets.ISO_8859_1);}}if(filename==null||fileBytes==null)throw new IllegalArgumentException("arquivo obrigatorio");return new Multipart(filename,tipo,fileBytes);} record Multipart(String filename,String tipo,byte[]bytes){}
}
@@ -0,0 +1,6 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDateTime;
public record Condominio(Long id, String nome, String cnpj, String endereco, LocalDateTime criadoEm) {
}
@@ -0,0 +1,6 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDateTime;
public record Contato(Long id, String nome, String telefone, String email, LocalDateTime criadoEm) {
}
@@ -0,0 +1,16 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDate;
public record ContatoCondominio(
Long contatoId,
Long condominioId,
String tipo,
LocalDate dataInicioMandato,
LocalDate dataFimMandato,
String status,
String contatoNome,
String contatoTelefone,
String contatoEmail
) {
}
@@ -0,0 +1,83 @@
package com.seconectai.condocontab.tenant.provisioning;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.db.MigrationRunner;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
public final class TenantProvisioner {
private final AppConfig config;
public TenantProvisioner(AppConfig config) {
this.config = config;
}
public ResultadoProvisionamento provisionar(String nomeEmpresa, String slug) {
String databaseName;
try {
databaseName = databaseNameFromSlug(slug);
} catch (IllegalArgumentException e) {
return ResultadoProvisionamento.falha(null, e.getMessage());
}
try {
criarDatabaseSeNaoExistir(databaseName);
try (var tenantConn = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword())) {
MigrationRunner.runMigrations(tenantConn, "migrations/tenant");
}
System.out.println("Tenant provisionado para " + nomeEmpresa + ": " + databaseName);
return ResultadoProvisionamento.sucesso(databaseName);
} catch (Exception e) {
System.err.println("Falha ao provisionar tenant " + databaseName + ": " + e.getMessage());
return ResultadoProvisionamento.falha(databaseName, e.getMessage());
}
}
public String databaseNameFromSlug(String slug) {
if (slug == null || slug.isBlank()) {
throw new IllegalArgumentException("Slug obrigatorio para provisionamento do tenant");
}
if (!slug.matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Slug invalido: use apenas letras minusculas, numeros, hifen ou underscore");
}
String normalized = slug.replace('-', '_');
if (!normalized.matches("[a-z0-9_]+")) {
throw new IllegalArgumentException("Slug invalido apos normalizacao");
}
return "db_empresa_" + normalized;
}
private void criarDatabaseSeNaoExistir(String databaseName) throws SQLException {
try (var conn = DriverManager.getConnection(config.postgresJdbcUrl(), config.dbUser(), config.dbPassword())) {
conn.setAutoCommit(true);
if (databaseExiste(conn.createStatement(), databaseName)) {
return;
}
try (Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + quoteIdentifier(databaseName));
}
}
}
private boolean databaseExiste(Statement stmt, String databaseName) throws SQLException {
try (var rs = stmt.executeQuery("SELECT 1 FROM pg_database WHERE datname = '" + databaseName + "'")) {
return rs.next();
}
}
private String quoteIdentifier(String identifier) {
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
public record ResultadoProvisionamento(boolean sucesso, String databaseName, String erro) {
public static ResultadoProvisionamento sucesso(String databaseName) {
return new ResultadoProvisionamento(true, databaseName, null);
}
public static ResultadoProvisionamento falha(String databaseName, String erro) {
return new ResultadoProvisionamento(false, databaseName, erro);
}
}
}
@@ -0,0 +1,65 @@
package com.seconectai.condocontab.util;
import java.time.temporal.TemporalAccessor;
import java.util.Iterator;
import java.util.Map;
public final class Json {
private Json() {
}
public static String write(Object value) {
return value(value);
}
private static String value(Object value) {
if (value == null) {
return "null";
}
if (value instanceof Number || value instanceof Boolean) {
return String.valueOf(value);
}
if (value instanceof TemporalAccessor) {
return quote(String.valueOf(value));
}
if (value instanceof Map<?, ?> map) {
StringBuilder json = new StringBuilder("{");
Iterator<? extends Map.Entry<?, ?>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
json.append(quote(String.valueOf(entry.getKey()))).append(":").append(value(entry.getValue()));
if (iterator.hasNext()) {
json.append(",");
}
}
return json.append("}").toString();
}
if (value instanceof Iterable<?> iterable) {
StringBuilder json = new StringBuilder("[");
Iterator<?> iterator = iterable.iterator();
while (iterator.hasNext()) {
json.append(value(iterator.next()));
if (iterator.hasNext()) {
json.append(",");
}
}
return json.append("]").toString();
}
return quote(String.valueOf(value));
}
private static String quote(String text) {
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> out.append(c);
}
}
return out.append("\"").toString();
}
}
@@ -0,0 +1,116 @@
package com.seconectai.condocontab.util;
import java.util.LinkedHashMap;
import java.util.Map;
public final class SimpleJson {
private SimpleJson() {
}
public static Map<String, String> parseObject(String json) {
Parser parser = new Parser(json);
return parser.parseObject();
}
private static final class Parser {
private final String text;
private int pos;
private Parser(String text) {
this.text = text == null ? "" : text;
}
private Map<String, String> parseObject() {
Map<String, String> values = new LinkedHashMap<>();
skipWs();
expect('{');
skipWs();
if (peek() == '}') {
pos++;
return values;
}
while (true) {
skipWs();
String key = parseString();
skipWs();
expect(':');
skipWs();
values.put(key, parseValueAsString());
skipWs();
char c = peek();
if (c == ',') {
pos++;
continue;
}
if (c == '}') {
pos++;
return values;
}
throw new IllegalArgumentException("JSON invalido na posicao " + pos);
}
}
private String parseValueAsString() {
char c = peek();
if (c == '"') {
return parseString();
}
int start = pos;
while (pos < text.length() && ",}".indexOf(text.charAt(pos)) < 0) {
pos++;
}
return text.substring(start, pos).trim();
}
private String parseString() {
expect('"');
StringBuilder out = new StringBuilder();
while (pos < text.length()) {
char c = text.charAt(pos++);
if (c == '"') {
return out.toString();
}
if (c == '\\') {
if (pos >= text.length()) {
throw new IllegalArgumentException("Escape JSON invalido");
}
char escaped = text.charAt(pos++);
switch (escaped) {
case '"' -> out.append('"');
case '\\' -> out.append('\\');
case '/' -> out.append('/');
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
default -> throw new IllegalArgumentException("Escape JSON nao suportado: " + escaped);
}
} else {
out.append(c);
}
}
throw new IllegalArgumentException("String JSON nao finalizada");
}
private void expect(char expected) {
if (peek() != expected) {
throw new IllegalArgumentException("Esperado '" + expected + "' na posicao " + pos);
}
pos++;
}
private char peek() {
if (pos >= text.length()) {
return '\0';
}
return text.charAt(pos);
}
private void skipWs() {
while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
pos++;
}
}
}
}
@@ -0,0 +1,9 @@
CREATE TABLE empresas (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
cnpj VARCHAR(18) UNIQUE,
slug VARCHAR(80) NOT NULL UNIQUE,
database_name VARCHAR(120) NOT NULL UNIQUE,
status VARCHAR(30) NOT NULL DEFAULT 'ativa',
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,12 @@
CREATE TABLE funcionarios (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
cargo VARCHAR(60),
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_funcionarios_empresa ON funcionarios(empresa_id);
@@ -0,0 +1,11 @@
CREATE TABLE api_keys (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
key_hash VARCHAR(255) NOT NULL UNIQUE,
descricao VARCHAR(255),
ativa BOOLEAN NOT NULL DEFAULT true,
criado_em TIMESTAMP NOT NULL DEFAULT now(),
revogada_em TIMESTAMP
);
CREATE INDEX idx_api_keys_empresa ON api_keys(empresa_id);
@@ -0,0 +1,6 @@
CREATE TABLE funcionalidades (
id BIGSERIAL PRIMARY KEY,
chave VARCHAR(80) NOT NULL UNIQUE,
nome VARCHAR(120) NOT NULL,
descricao TEXT
);
@@ -0,0 +1,8 @@
CREATE TABLE empresa_funcionalidades (
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
funcionalidade_id BIGINT NOT NULL REFERENCES funcionalidades(id),
ativa BOOLEAN NOT NULL DEFAULT true,
liberado_em TIMESTAMP NOT NULL DEFAULT now(),
liberado_por BIGINT REFERENCES funcionarios(id),
PRIMARY KEY (empresa_id, funcionalidade_id)
);
@@ -0,0 +1,13 @@
CREATE TABLE evolution_instancias (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
instance_url VARCHAR(255) NOT NULL,
instance_token_enc TEXT,
numero_whatsapp VARCHAR(20),
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
solicitado_em TIMESTAMP NOT NULL DEFAULT now(),
aprovado_em TIMESTAMP,
aprovado_por BIGINT REFERENCES funcionarios(id)
);
CREATE INDEX idx_evolution_empresa ON evolution_instancias(empresa_id);
@@ -0,0 +1,5 @@
CREATE TABLE configuracoes_plataforma (
chave VARCHAR(100) PRIMARY KEY,
valor TEXT,
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,8 @@
CREATE TABLE planos (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(120) NOT NULL,
valor_mensal_centavos BIGINT NOT NULL,
descricao TEXT,
ativo BOOLEAN NOT NULL DEFAULT true,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,5 @@
CREATE TABLE plano_funcionalidades (
plano_id BIGINT NOT NULL REFERENCES planos(id),
funcionalidade_id BIGINT NOT NULL REFERENCES funcionalidades(id),
PRIMARY KEY (plano_id, funcionalidade_id)
);
@@ -0,0 +1,11 @@
CREATE TABLE assinaturas (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
plano_id BIGINT NOT NULL REFERENCES planos(id),
valor_mensal_centavos BIGINT NOT NULL,
dia_vencimento INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ativa',
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_assinaturas_empresa ON assinaturas(empresa_id);
@@ -0,0 +1,11 @@
CREATE TABLE periodos_uso (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
mes_referencia DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
data_liberacao TIMESTAMP,
data_bloqueio TIMESTAMP,
UNIQUE (empresa_id, mes_referencia)
);
CREATE INDEX idx_periodos_uso_empresa ON periodos_uso(empresa_id);
@@ -0,0 +1,15 @@
CREATE TABLE pagamentos (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
periodo_uso_id BIGINT NOT NULL REFERENCES periodos_uso(id),
valor_centavos BIGINT NOT NULL,
abacatepay_charge_id VARCHAR(120) UNIQUE,
qr_code_payload TEXT,
qr_code_base64 TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
criado_em TIMESTAMP NOT NULL DEFAULT now(),
pago_em TIMESTAMP
);
CREATE INDEX idx_pagamentos_empresa ON pagamentos(empresa_id);
CREATE INDEX idx_pagamentos_periodo ON pagamentos(periodo_uso_id);
@@ -0,0 +1,14 @@
CREATE TABLE logs_envio_avisos (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
condominio_id BIGINT,
aviso_id BIGINT,
contato_id BIGINT,
contato_nome VARCHAR(150),
canal VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
erro_detalhe TEXT,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_logs_envio_empresa ON logs_envio_avisos(empresa_id, status);
@@ -0,0 +1,8 @@
CREATE TABLE lembretes_cobranca (
id BIGSERIAL PRIMARY KEY,
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
periodo_uso_id BIGINT NOT NULL REFERENCES periodos_uso(id),
canal VARCHAR(20) NOT NULL,
enviado_por BIGINT REFERENCES funcionarios(id),
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,32 @@
INSERT INTO funcionalidades (chave, nome, descricao) VALUES
('condominios_crud', 'Cadastro de condomínios', 'Criar, listar e editar condomínios do escritório.'),
('contatos_crud', 'Cadastro de contatos', 'Criar e manter síndicos, subsíndicos e conselheiros.'),
('contatos_vinculos', 'Vínculos de contatos', 'Vincular contatos a condomínios com papéis e mandatos.'),
('funcionarios_vinculos', 'Funcionários vinculados', 'Vincular funcionários do escritório aos condomínios atendidos.'),
('espacos_comuns_crud', 'Espaços comuns', 'Cadastrar áreas comuns e regras básicas de utilização.'),
('assembleias_crud', 'Assembleias', 'Cadastrar assembleias ordinárias e extraordinárias.'),
('documentos_upload', 'Upload de documentos', 'Arquivar documentos dos condomínios.'),
('documentos_download', 'Download de documentos', 'Baixar documentos arquivados.'),
('avisos_crud', 'Avisos e alertas', 'Criar avisos e alertas vinculados a condomínios.'),
('avisos_documentos', 'Anexos em avisos', 'Anexar documentos já arquivados aos avisos.'),
('avisos_destinatarios', 'Destinatários de avisos', 'Selecionar contatos e funcionários como destinatários.'),
('whatsapp_envio', 'Envio por WhatsApp', 'Enviar avisos pela Evolution API.'),
('email_envio', 'Envio por e-mail', 'Enviar avisos por e-mail SMTP.'),
('financeiro_cliente', 'Financeiro do escritório', 'Consultar período de uso e gerar cobrança mensal.'),
('abacatepay_pix', 'PIX AbacatePay', 'Gerar cobranças PIX via AbacatePay.'),
('api_externa', 'API externa', 'Permitir acesso externo via API Key.'),
('dashboard_master', 'Dashboard master', 'Acessar painel master de clientes e planos.'),
('dashboard_cliente', 'Dashboard cliente', 'Acessar painel do escritório.'),
('planos_crud', 'Planos', 'Criar e editar planos comerciais.'),
('funcionalidades_toggle', 'Controle de funcionalidades', 'Ativar ou desativar funcionalidades por empresa.'),
('logs_envio', 'Logs de envio', 'Consultar logs de WhatsApp e e-mail.'),
('lembretes_cobranca', 'Lembretes de cobrança', 'Registrar lembretes de cobrança para clientes.'),
('auditoria', 'Auditoria', 'Registrar ações de escrita no tenant.'),
('configuracoes_integracao', 'Configurações de integração', 'Gerenciar Evolution API e SMTP.'),
('templates_avisos', 'Templates de avisos', 'Criar modelos reutilizáveis de mensagens.'),
('avisos_recorrentes', 'Avisos recorrentes', 'Agendar avisos recorrentes.'),
('webhooks_externos', 'Webhooks externos', 'Publicar eventos para sistemas externos.'),
('busca_avancada', 'Busca avançada', 'Buscar dados com filtros avançados.'),
('exportacao_dados', 'Exportação de dados', 'Exportar cadastros e relatórios.'),
('relatorios_gerenciais', 'Relatórios gerenciais', 'Gerar relatórios consolidados para o escritório.')
ON CONFLICT (chave) DO NOTHING;
@@ -0,0 +1,16 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM evolution_instancias
WHERE numero_whatsapp IS NOT NULL
GROUP BY numero_whatsapp
HAVING count(*) > 1
) THEN
CREATE UNIQUE INDEX IF NOT EXISTS ux_evolution_numero_whatsapp
ON evolution_instancias (numero_whatsapp)
WHERE numero_whatsapp IS NOT NULL;
ELSE
RAISE NOTICE 'Indice unico ux_evolution_numero_whatsapp nao criado: existem numeros duplicados para revisar manualmente.';
END IF;
END $$;
@@ -0,0 +1 @@
ALTER TABLE funcionarios ADD COLUMN IF NOT EXISTS telefone VARCHAR(30);
@@ -0,0 +1,37 @@
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_inicio DATE;
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_fim DATE;
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_vencimento DATE;
UPDATE periodos_uso pu
SET data_inicio = COALESCE(data_inicio, mes_referencia),
data_fim = COALESCE(data_fim, (date_trunc('month', mes_referencia)::date + interval '1 month - 1 day')::date),
data_vencimento = COALESCE(data_vencimento, (date_trunc('month', mes_referencia)::date + interval '1 month' + ((COALESCE(a.dia_vencimento, 7) - 1) || ' days')::interval)::date)
FROM assinaturas a
WHERE a.empresa_id = pu.empresa_id
AND a.status = 'ativa';
UPDATE periodos_uso
SET data_inicio = COALESCE(data_inicio, mes_referencia),
data_fim = COALESCE(data_fim, (date_trunc('month', mes_referencia)::date + interval '1 month - 1 day')::date),
data_vencimento = COALESCE(data_vencimento, (date_trunc('month', mes_referencia)::date + interval '1 month 6 days')::date);
ALTER TABLE periodos_uso ALTER COLUMN data_inicio SET NOT NULL;
ALTER TABLE periodos_uso ALTER COLUMN data_fim SET NOT NULL;
ALTER TABLE periodos_uso ALTER COLUMN data_vencimento SET NOT NULL;
UPDATE assinaturas SET dia_vencimento = 7 WHERE dia_vencimento IS NULL OR dia_vencimento = 10;
INSERT INTO periodos_uso (empresa_id, mes_referencia, data_inicio, data_fim, data_vencimento, status)
SELECT e.id,
date_trunc('month', current_date - interval '1 month')::date,
date_trunc('month', current_date - interval '1 month')::date,
(date_trunc('month', current_date)::date - interval '1 day')::date,
(date_trunc('month', current_date)::date + ((COALESCE(a.dia_vencimento, 7) - 1) || ' days')::interval)::date,
'pendente'
FROM empresas e
JOIN assinaturas a ON a.empresa_id = e.id AND a.status = 'ativa'
WHERE e.status <> 'excluida'
ON CONFLICT (empresa_id, mes_referencia) DO UPDATE
SET data_inicio = EXCLUDED.data_inicio,
data_fim = EXCLUDED.data_fim,
data_vencimento = EXCLUDED.data_vencimento;
@@ -0,0 +1,7 @@
CREATE TABLE condominios (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
cnpj VARCHAR(18) UNIQUE,
endereco VARCHAR(255),
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,7 @@
CREATE TABLE contatos (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
telefone VARCHAR(20),
email VARCHAR(150),
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
CREATE TABLE contato_condominio (
contato_id BIGINT NOT NULL REFERENCES contatos(id),
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(20) NOT NULL,
data_inicio_mandato DATE,
data_fim_mandato DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
PRIMARY KEY (contato_id, condominio_id, tipo)
);
@@ -0,0 +1,5 @@
CREATE TABLE funcionario_condominio (
funcionario_id BIGINT NOT NULL,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
PRIMARY KEY (funcionario_id, condominio_id)
);
@@ -0,0 +1,10 @@
CREATE TABLE espacos_comuns (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
nome VARCHAR(100) NOT NULL,
descricao TEXT,
valor_utilizacao_centavos BIGINT,
indice_correcao VARCHAR(30),
data_ultimo_reajuste DATE,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,10 @@
CREATE TABLE assembleias (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(20) NOT NULL,
data_hora TIMESTAMP NOT NULL,
local VARCHAR(150),
pauta TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'agendada',
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,10 @@
CREATE TABLE documentos (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
tipo VARCHAR(40),
nome_arquivo VARCHAR(255) NOT NULL,
caminho_armazenamento VARCHAR(500) NOT NULL,
versao INT NOT NULL DEFAULT 1,
enviado_por BIGINT,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,17 @@
CREATE TABLE avisos (
id BIGSERIAL PRIMARY KEY,
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
assembleia_id BIGINT REFERENCES assembleias(id),
tipo VARCHAR(20) NOT NULL DEFAULT 'aviso',
titulo VARCHAR(150) NOT NULL,
mensagem TEXT NOT NULL,
data_evento TIMESTAMP,
data_publicacao TIMESTAMP NOT NULL DEFAULT now(),
data_expiracao TIMESTAMP,
prioridade VARCHAR(10) DEFAULT 'normal',
enviar_whatsapp BOOLEAN NOT NULL DEFAULT false,
enviar_email BOOLEAN NOT NULL DEFAULT false,
status_envio VARCHAR(20) NOT NULL DEFAULT 'nao_enviado',
criado_por_funcionario BIGINT,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,5 @@
CREATE TABLE aviso_documentos (
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
documento_id BIGINT NOT NULL REFERENCES documentos(id),
PRIMARY KEY (aviso_id, documento_id)
);
@@ -0,0 +1,6 @@
CREATE TABLE aviso_destinatarios (
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
tipo_destinatario VARCHAR(20) NOT NULL,
destinatario_id BIGINT NOT NULL,
PRIMARY KEY (aviso_id, tipo_destinatario, destinatario_id)
);
@@ -0,0 +1,10 @@
CREATE TABLE log_auditoria (
id BIGSERIAL PRIMARY KEY,
funcionario_id BIGINT,
acao VARCHAR(20) NOT NULL,
tabela_afetada VARCHAR(60) NOT NULL,
registro_id BIGINT,
dados_anteriores JSONB,
dados_novos JSONB,
criado_em TIMESTAMP NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
CREATE INDEX idx_contato_condominio_condominio ON contato_condominio(condominio_id);
CREATE INDEX idx_contato_condominio_contato ON contato_condominio(contato_id);
CREATE INDEX idx_funcionario_condominio_condominio ON funcionario_condominio(condominio_id);
CREATE INDEX idx_espacos_condominio ON espacos_comuns(condominio_id);
CREATE INDEX idx_assembleias_condominio ON assembleias(condominio_id, data_hora);
CREATE INDEX idx_documentos_condominio ON documentos(condominio_id);
CREATE INDEX idx_avisos_condominio ON avisos(condominio_id, data_publicacao);
CREATE INDEX idx_avisos_assembleia ON avisos(assembleia_id);
CREATE INDEX idx_aviso_destinatarios_aviso ON aviso_destinatarios(aviso_id);
@@ -0,0 +1,2 @@
ALTER TABLE condominios
ADD CONSTRAINT condominios_cnpj_obrigatorio CHECK (cnpj IS NOT NULL AND btrim(cnpj) <> '') NOT VALID;
@@ -0,0 +1,19 @@
CREATE TABLE lembretes (
id BIGSERIAL PRIMARY KEY,
titulo VARCHAR(160) NOT NULL,
mensagem TEXT NOT NULL,
data_hora TIMESTAMP,
status VARCHAR(30) NOT NULL DEFAULT 'ativo',
criado_por_funcionario BIGINT NOT NULL,
criado_em TIMESTAMP NOT NULL DEFAULT now(),
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE lembrete_destinatarios (
lembrete_id BIGINT NOT NULL REFERENCES lembretes(id) ON DELETE CASCADE,
funcionario_id BIGINT NOT NULL,
PRIMARY KEY (lembrete_id, funcionario_id)
);
CREATE INDEX idx_lembretes_data_hora ON lembretes(data_hora);
CREATE INDEX idx_lembrete_destinatarios_funcionario ON lembrete_destinatarios(funcionario_id);
@@ -0,0 +1,30 @@
CREATE TABLE fila_envio (
id BIGSERIAL PRIMARY KEY,
tipo VARCHAR(30) NOT NULL,
referencia_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
agendado_para TIMESTAMP NOT NULL,
tentativas INTEGER NOT NULL DEFAULT 0,
ultimo_erro TEXT,
locked_at TIMESTAMP,
criado_em TIMESTAMP NOT NULL DEFAULT now(),
atualizado_em TIMESTAMP NOT NULL DEFAULT now(),
UNIQUE (tipo, referencia_id)
);
CREATE INDEX idx_fila_envio_pendentes ON fila_envio(status, agendado_para);
INSERT INTO fila_envio (tipo, referencia_id, status, agendado_para)
SELECT 'lembrete', id, 'pendente', COALESCE(data_hora, now())
FROM lembretes
WHERE status = 'ativo'
ON CONFLICT (tipo, referencia_id) DO UPDATE
SET status = 'pendente', agendado_para = EXCLUDED.agendado_para, atualizado_em = now();
INSERT INTO fila_envio (tipo, referencia_id, status, agendado_para)
SELECT 'aviso', id, 'pendente', COALESCE(data_evento, now())
FROM avisos
WHERE (enviar_whatsapp = true OR enviar_email = true)
AND status_envio IN ('nao_enviado', 'agendado')
ON CONFLICT (tipo, referencia_id) DO UPDATE
SET status = 'pendente', agendado_para = EXCLUDED.agendado_para, atualizado_em = now();
@@ -0,0 +1,10 @@
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS recorrencia_meses INTEGER NOT NULL DEFAULT 0;
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS recorrencia_ativa BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS ultimo_envio TIMESTAMP;
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS recorrencia_meses INTEGER NOT NULL DEFAULT 0;
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS recorrencia_ativa BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS ultimo_envio TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_lembretes_recorrencia ON lembretes(recorrencia_ativa, data_hora);
CREATE INDEX IF NOT EXISTS idx_avisos_recorrencia ON avisos(recorrencia_ativa, data_evento);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS destinatarios_extra (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(160) NOT NULL,
email VARCHAR(160),
telefone VARCHAR(30),
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
criado_em TIMESTAMP NOT NULL DEFAULT now(),
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_destinatarios_extra_status ON destinatarios_extra(status, nome);
@@ -0,0 +1 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Detalhe do Condominio</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Detalhe do Condominio</h1></div><section class="panel" id="header"></section><div class="nav-tabs"><button data-tab="contatos" class="active">Contatos</button><button data-tab="espacos">Espacos</button><button data-tab="assembleias">Assembleias</button><button data-tab="documentos">Documentos</button><button data-tab="avisos">Avisos</button></div><section class="panel tab" id="contatos"></section><section class="panel tab hidden" id="espacos"></section><section class="panel tab hidden" id="assembleias"></section><section class="panel tab hidden" id="documentos"></section><section class="panel tab hidden" id="avisos"></section></main><script>Layout.mount('empresa','condominios');</script><script src="/cliente/condominio-detalhe.js"></script></body></html>
@@ -0,0 +1,30 @@
const id=new URLSearchParams(location.search).get('id');const sections=['contatos','espacos','assembleias','documentos','avisos'];document.querySelectorAll('.nav-tabs button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');sections.forEach(s=>document.querySelector('#'+s).classList.toggle('hidden',s!==b.dataset.tab))});let contatos=[],docs=[],avisos=[],funcionariosVinculados=[],extras=[],assembleiasCalendario=[],calMonth=new Date();function recLabel(v){return {1:'Mensal',3:'Trimestral',12:'Anual'}[Number(v)]||'Sem recorrencia'}
function dateKey(v){const d=new Date(v);if(Number.isNaN(d.getTime()))return'';const pad=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())}
function monthLabel(d){return d.toLocaleDateString('pt-BR',{month:'long',year:'numeric'})}
function ensureCalendarModal(){let m=document.querySelector('#assembleia-calendar-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="assembleia-calendar-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharCalendarioAssembleias()"></div><section class="modal-panel calendar-modal"><div class="modal-head"><h2>Calendário de assembleias</h2><button class="btn secondary" type="button" onclick="fecharCalendarioAssembleias()">Fechar</button></div><div class="calendar-layout"><div><div class="calendar-toolbar"><button class="btn secondary" type="button" onclick="mudarMesCalendario(-1)">Anterior</button><strong id="calendar-title"></strong><button class="btn secondary" type="button" onclick="mudarMesCalendario(1)">Próximo</button></div><div id="calendar-grid" class="calendar-grid"></div></div><aside class="calendar-side"><h3 id="calendar-day-title">Selecione um dia</h3><div id="calendar-day-list" class="calendar-day-list"><p class="muted">Clique em um dia para ver as assembleias e notificados.</p></div></aside></div></section></div>');return document.querySelector('#assembleia-calendar-modal')}
function renderCalendarioAssembleias(selected){ensureCalendarModal();const title=document.querySelector('#calendar-title'),grid=document.querySelector('#calendar-grid');title.textContent=monthLabel(calMonth);const y=calMonth.getFullYear(),m=calMonth.getMonth(),first=new Date(y,m,1),start=new Date(y,m,1-first.getDay()),todayKey=dateKey(new Date());let html=['Dom','Seg','Ter','Qua','Qui','Sex','Sáb'].map(x=>'<div class="calendar-weekday">'+x+'</div>').join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(start.getDate()+i);const key=dateKey(d),items=assembleiasCalendario.filter(a=>dateKey(a.data_hora)===key),other=d.getMonth()!==m,classes=['calendar-day'];if(other)classes.push('other');if(items.length)classes.push('busy');if(key===todayKey)classes.push('today');html+='<button type="button" class="'+classes.join(' ')+'" data-date="'+key+'"><span>'+d.getDate()+'</span>'+(items.length?'<b>'+items.length+'</b>':'')+'</button>'}grid.innerHTML=html;grid.querySelectorAll('.calendar-day').forEach(btn=>btn.onclick=()=>selecionarDiaCalendario(btn.dataset.date));if(selected)selecionarDiaCalendario(selected,false)}
window.abrirCalendarioAssembleias=async()=>{try{const d=await Api.get('/api/assembleias-calendario');assembleiasCalendario=d.assembleias||[];calMonth=new Date();ensureCalendarModal().classList.remove('hidden');renderCalendarioAssembleias()}catch(e){Util.toast(e.message,true)}};
window.fecharCalendarioAssembleias=()=>document.querySelector('#assembleia-calendar-modal')?.classList.add('hidden');
window.mudarMesCalendario=delta=>{calMonth=new Date(calMonth.getFullYear(),calMonth.getMonth()+delta,1);renderCalendarioAssembleias()};
window.selecionarDiaCalendario=(key,mark=true)=>{if(mark)document.querySelectorAll('.calendar-day').forEach(x=>x.classList.toggle('selected',x.dataset.date===key));const list=document.querySelector('#calendar-day-list'),title=document.querySelector('#calendar-day-title'),items=assembleiasCalendario.filter(a=>dateKey(a.data_hora)===key);const [yy,mm,dd]=key.split('-');title.textContent=dd+'/'+mm+'/'+yy;if(!items.length){list.innerHTML='<p class="muted">Nenhuma assembleia agendada neste dia.</p>';return;}list.innerHTML=items.map(a=>{const dest=(a.destinatarios||[]).map(x=>x.nome+' ('+x.tipo+')').join(', ')||'Nenhum destinatário registrado';return '<div class="calendar-event"><strong>'+a.condominio_nome+'</strong><span>'+Util.date(a.data_hora)+'</span><span>'+a.tipo+' - '+(a.local||'-')+'</span><p>'+dest+'</p><div class="row compact"><button class="btn secondary" type="button" onclick="reenviarAvisoAssembleia('+a.condominio_id+','+a.id+')">Reenviar notificação</button><button class="btn danger" type="button" onclick="cancelarAssembleiaCalendario('+a.condominio_id+','+a.id+')">Cancelar assembleia</button></div></div>'}).join('')};
window.reenviarAvisoAssembleia=async(cond,aid)=>{try{const a=assembleiasCalendario.find(x=>Number(x.id)===Number(aid));if(!a||!a.aviso_id)throw new Error('Aviso da assembleia não encontrado.');await Api.post('/api/condominios/'+cond+'/avisos/'+a.aviso_id+'/reenviar',{});Util.toast('Notificação reenviada')}catch(e){Util.toast(e.message,true)}};
window.cancelarAssembleiaCalendario=async(cond,aid)=>{if(!confirm('Cancelar esta assembleia?'))return;try{await Api.put('/api/condominios/'+cond+'/assembleias/'+aid,{status:'cancelada'});Util.toast('Assembleia cancelada');await abrirCalendarioAssembleias();await loadAssembleias();await load()}catch(e){Util.toast(e.message,true)}};
function ensureAvisoNotifModal(){let m=document.querySelector('#aviso-notif-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="aviso-notif-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharAvisoNotificacao()"></div><section class="modal-panel"><div class="modal-head"><h2>Notificação</h2><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Fechar</button></div><div id="aviso-notif-body"></div><div class="row"><button class="btn" type="button" onclick="salvarAvisoNotificacao()">Salvar</button><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Cancelar</button></div></section></div>');return document.querySelector('#aviso-notif-modal')}
function avisoDestInputs(){return [...document.querySelectorAll('#aviso-dests-hidden input[name=dest]')]}
function updateAvisoNotifSummary(){const n=avisoDestInputs().filter(x=>x.checked).length;const el=document.querySelector('#aviso-notif-summary');if(el)el.textContent=n?(n+' selecionado'+(n>1?'s':'')):'Nenhuma pessoa selecionada'}
window.abrirAvisoNotificacao=()=>{const m=ensureAvisoNotifModal(),ids=new Set(avisoDestInputs().filter(x=>x.checked).map(x=>x.value));const pessoas=[...contatos.map(c=>({value:'contato:'+c.contato_id,nome:c.contato_nome,tipo:c.tipo})),...funcionariosVinculados.map(f=>({value:'funcionario:'+(f.funcionario_id||f.id),nome:f.nome,tipo:'funcionario'})),...extras.map(x=>({value:'extra:'+x.id,nome:x.nome,tipo:'extra'}))];document.querySelector('#aviso-notif-body').innerHTML=pessoas.map(p=>'<label><input type="checkbox" value="'+p.value+'" '+(ids.has(p.value)?'checked':'')+'> '+p.nome+' ('+p.tipo+')</label>').join('')||'<p class="muted">Nenhuma pessoa cadastrada.</p>';m.classList.remove('hidden')};
window.fecharAvisoNotificacao=()=>document.querySelector('#aviso-notif-modal')?.classList.add('hidden');
window.salvarAvisoNotificacao=()=>{const ids=new Set([...document.querySelectorAll('#aviso-notif-body input:checked')].map(x=>x.value));avisoDestInputs().forEach(x=>x.checked=ids.has(x.value));updateAvisoNotifSummary();fecharAvisoNotificacao()};
async function safeLoad(section,fn){try{await fn()}catch(e){document.querySelector('#'+section).innerHTML=`<p>${e.message}</p>`;Util.toast(e.message,true)}}
async function load(){try{const d=await Api.get(`/api/condominios/${id}`);document.querySelector('#header').innerHTML=`<h2>${d.condominio.nome}</h2><p>${d.condominio.endereco||''}</p><div class="cards"><div class="stat">Sindico<strong>${d.sindico_atual?.contato_nome||'-'}</strong></div><div class="stat">Assembleias futuras<strong>${d.assembleias_futuras||0}</strong></div><div class="stat">Avisos ativos<strong>${d.avisos_ativos||0}</strong></div></div>`;await safeLoad('contatos',loadContatos);await safeLoad('espacos',loadEspacos);await safeLoad('assembleias',loadAssembleias);await safeLoad('documentos',loadDocs);await safeLoad('avisos',loadFuncionariosVinculados);await safeLoad('avisos',loadExtras);await safeLoad('avisos',loadAvisos)}catch(e){Util.toast(e.message,true)}}
async function loadContatos(){const d=await Api.get(`/api/condominios/${id}/contatos`);contatos=d.contatos||[];document.querySelector('#contatos').innerHTML=`<h2>Contatos</h2><form id="contato-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Telefone</label><input name="telefone"></div><div class="field"><label>E-mail</label><input name="email"></div><div class="field"><label>Tipo</label><select name="tipo"><option value="sindico">Sindico</option><option value="subsindico">Subsindico</option><option value="conselheiro">Conselheiro</option></select></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>Tipo</th><th>Telefone</th><th>Email</th></tr></thead><tbody>${contatos.map(c=>`<tr><td>${c.contato_nome}</td><td>${c.tipo}</td><td>${c.contato_telefone||''}</td><td>${c.contato_email||''}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#contato-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/contatos/vincular`,Util.formData(e.target));Util.toast('Contato salvo');loadContatos()}catch(err){Util.toast(err.message,true)}}}
async function loadEspacos(){const d=await Api.get(`/api/condominios/${id}/espacos-comuns`);document.querySelector('#espacos').innerHTML=`<h2>Espacos comuns</h2><form id="espaco-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Valor centavos</label><input name="valor_utilizacao_centavos" type="number"></div><div class="field"><label>Indice</label><input name="indice_correcao"></div><div class="field"><label>Descricao</label><input name="descricao"></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>Valor</th><th>Indice</th><th>Descricao</th></tr></thead><tbody>${(d.espacos||[]).map(x=>`<tr><td>${x.nome}</td><td>${Util.money(x.valor_utilizacao_centavos)}</td><td>${x.indice_correcao||''}</td><td>${x.descricao||''}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#espaco-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/espacos-comuns`,Util.formData(e.target));Util.toast('Espaco salvo');loadEspacos()}catch(err){Util.toast(err.message,true)}}}
async function loadAssembleias(){const d=await Api.get(`/api/condominios/${id}/assembleias`);document.querySelector('#assembleias').innerHTML=`<div class="section-head"><h2>Assembleias</h2><button class="icon-btn calendar-open" type="button" onclick="abrirCalendarioAssembleias()" title="Calendário de assembleias" aria-label="Calendário de assembleias"></button></div><form id="ass-form" class="grid cols"><div class="field"><label>Tipo</label><input name="tipo" required></div><div class="field"><label>Data e hora</label><input name="data_hora" type="datetime-local" required></div><div class="field"><label>Local</label><input name="local"></div><div class="field"><label>Pauta</label><input name="pauta"></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Tipo</th><th>Data</th><th>Local</th><th>Status</th></tr></thead><tbody>${(d.assembleias||[]).map(a=>`<tr><td>${a.tipo}</td><td>${Util.date(a.data_hora)}</td><td>${a.local||''}</td><td>${Util.badge(a.status)}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#ass-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/assembleias`,Util.formData(e.target));Util.toast('Assembleia salva');loadAssembleias()}catch(err){Util.toast(err.message,true)}}}
async function loadFuncionariosVinculados(){const d=await Api.get(`/api/condominios/${id}/funcionarios-vinculados`);funcionariosVinculados=d.funcionarios||[]}
async function loadExtras(){const d=await Api.get(`/api/extras`);extras=(d.extras||[]).filter(x=>x.status==='ativo')}
async function loadDocs(){const d=await Api.get(`/api/condominios/${id}/documentos`);docs=d.documentos||[];document.querySelector('#documentos').innerHTML=`<h2>Documentos</h2><form id="doc-form" class="row"><div class="field"><label>Tipo</label><input name="tipo"></div><div class="field"><label>Arquivo</label><input name="arquivo" type="file" required></div><button class="btn">Enviar</button></form><div class="table-wrap"><table><thead><tr><th>ID</th><th>Arquivo</th><th>Tipo</th><th>Download</th></tr></thead><tbody>${docs.map(d=>`<tr><td>${d.id}</td><td>${d.nome_arquivo}</td><td>${d.tipo||''}</td><td><a href="/api/documentos/${d.id}/download">Baixar</a></td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#doc-form').onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target);try{await Api.post(`/api/condominios/${id}/documentos`,fd);Util.toast('Documento enviado');loadDocs()}catch(err){Util.toast(err.message,true)}}}
async function loadAvisos(){const d=await Api.get(`/api/condominios/${id}/avisos`);avisos=d.avisos||[];const destContatos=contatos.map(c=>`<label><input type="checkbox" name="dest" value="contato:${c.contato_id}"> ${c.contato_nome} (${c.tipo})</label>`).join('');const destFuncionarios=funcionariosVinculados.map(f=>`<label><input type="checkbox" name="dest" value="funcionario:${f.funcionario_id||f.id}"> ${f.nome} (funcionario)</label>`).join('');const destExtras=extras.map(x=>`<label><input type="checkbox" name="dest" value="extra:${x.id}"> ${x.nome} (extra)</label>`).join('');const dest=[destContatos,destFuncionarios,destExtras].filter(Boolean).join('');const docChecks=docs.map(d=>`<label><input type="checkbox" name="doc" value="${d.id}"> ${d.nome_arquivo}</label>`).join('');document.querySelector('#avisos').innerHTML=`<h2>Avisos</h2><form id="aviso-form" class="grid cols"><div class="field"><label>Titulo</label><input name="titulo" required></div><div class="field"><label>Tipo</label><input name="tipo" value="aviso"></div><div class="field"><label>Prioridade</label><select name="prioridade"><option>normal</option><option>alta</option><option>urgente</option></select></div><div class="field"><label>Mensagem</label><textarea name="mensagem" required></textarea></div><div class="field"><label>Data/hora de envio</label><input name="data_evento" type="datetime-local"></div><div class="field"><label>Recorrencia</label><select name="recorrencia_meses"><option value="0">Sem recorrencia</option><option value="1">1 vez por mes</option><option value="3">A cada 3 meses</option><option value="12">1 vez por ano</option></select></div><div class="field"><label>Destinatarios</label><button class="btn secondary" type="button" onclick="abrirAvisoNotificacao()">Notificação</button> <span id="aviso-notif-summary" class="muted">Nenhuma pessoa selecionada</span><div id="aviso-dests-hidden" class="hidden">${dest}</div></div><div class="field"><label>Documentos</label>${docChecks||'<p>Nenhum documento.</p>'}</div><label><input type="checkbox" name="enviar_whatsapp" value="true"> Enviar por WhatsApp</label><label><input type="checkbox" name="enviar_email" value="true"> Enviar por e-mail</label><button class="btn">Criar aviso</button></form><div class="table-wrap"><table><thead><tr><th>Titulo</th><th>Status</th><th>Envio</th><th>Publicado em</th><th>Recorrencia</th><th>Acoes</th></tr></thead><tbody>${avisos.map(a=>`<tr><td>${a.titulo}</td><td>${Util.badge(a.status_envio)}</td><td>${Util.date(a.data_evento)}</td><td>${Util.date(a.data_publicacao)}</td><td>${recLabel(a.recorrencia_meses)}</td><td><button class="btn secondary" onclick="reenviarAviso(${a.id})">Reenviar</button> <button class="btn danger" onclick="excluirAviso(${a.id})">Excluir</button></td></tr>`).join('')}</tbody></table></div>`;updateAvisoNotifSummary();document.querySelector('#aviso-form').onsubmit=async e=>{e.preventDefault();const f=e.target,b=Util.formData(f);b.enviar_whatsapp=!!f.enviar_whatsapp.checked;b.enviar_email=!!f.enviar_email.checked;b.destinatarios=[...f.querySelectorAll('input[name=dest]:checked')].map(x=>{const [tipo,did]=x.value.split(':');return{tipo_destinatario:tipo,id:Number(did)}});b.documentos=[...f.querySelectorAll('input[name=doc]:checked')].map(x=>Number(x.value));try{await Api.post(`/api/condominios/${id}/avisos`,b);Util.toast('Aviso criado');loadAvisos();load()}catch(err){Util.toast(err.message,true)}}}
window.reenviarAviso=async avisoId=>{try{await Api.post(`/api/condominios/${id}/avisos/${avisoId}/reenviar`,{});Util.toast('Aviso reenviado');loadAvisos()}catch(err){Util.toast(err.message,true)}};
window.excluirAviso=async avisoId=>{try{await Api.request(`/api/condominios/${id}/avisos/${avisoId}`,{method:'DELETE'});Util.toast('Aviso excluido');loadAvisos();load()}catch(err){Util.toast(err.message,true)}};
load();
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Cadastro</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Cadastro</h1></div><section class="panel"><h2>Novo condominio</h2><form id="cond-form" class="row"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>CNPJ</label><input name="cnpj" required></div><div class="field"><label>Endereco</label><input name="endereco"></div><button class="btn">Criar</button></form><div class="panel-block"><h2>Condominios</h2><div class="table-wrap"><table><thead><tr><th>ID</th><th>Nome</th><th>CNPJ</th><th>Endereco</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></div></section><section id="funcionarios-panel" class="panel hidden"><h2>Funcionarios</h2><form id="func-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>E-mail de login</label><input name="email" type="email" required></div><div class="field"><label>Telefone/WhatsApp</label><input name="telefone"></div><div class="field"><label>Senha inicial</label><input name="senha" type="password" required></div><button class="btn">Cadastrar funcionario</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>E-mail</th><th>Telefone</th><th>Cargo</th><th>Status</th><th>Condominios</th><th>Opcoes</th></tr></thead><tbody id="func-rows"></tbody></table></div></section><section id="extras-panel" class="panel hidden"><h2>Extra</h2><form id="extra-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>E-mail</label><input name="email" type="email"></div><div class="field"><label>WhatsApp</label><input name="telefone"></div><button class="btn">Cadastrar extra</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>E-mail</th><th>WhatsApp</th><th>Status</th><th>Opcoes</th></tr></thead><tbody id="extra-rows"></tbody></table></div></section><div id="cond-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharModalCondominios()"></div><section class="modal-panel"><div class="modal-head"><h2 id="cond-modal-title">Condominios</h2><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Fechar</button></div><div id="cond-modal-body"></div></section></div></main><script>Layout.mount('empresa','condominios');</script><script src="/cliente/condominios.js"></script></body></html>
+29
View File
@@ -0,0 +1,29 @@
const form=document.querySelector('#cond-form'),rows=document.querySelector('#rows');
let condominios=[],funcionarios=[],extras=[],funcLinks=new Map(),funcAtual=null,funcEditId=null;
async function load(){try{const d=await Api.get('/api/condominios');condominios=d.condominios||[];rows.innerHTML=condominios.map(c=>`<tr><td>${c.id}</td><td>${c.nome}</td><td>${c.cnpj||''}</td><td>${c.endereco||''}</td><td><a class="btn secondary" href="/cliente/condominio-detalhe.html?id=${c.id}">Abrir</a></td></tr>`).join('')}catch(e){Util.toast(e.message,true)}}
form.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/condominios',Util.formData(form));form.reset();Util.toast('Condominio criado');await load();await loadFuncionarios()}catch(err){Util.toast(err.message,true)}};
load();
const funcPanel=document.querySelector('#funcionarios-panel'),funcForm=document.querySelector('#func-form'),funcRows=document.querySelector('#func-rows'),extraPanel=document.querySelector('#extras-panel'),extraForm=document.querySelector('#extra-form'),extraRows=document.querySelector('#extra-rows'),modal=document.querySelector('#cond-modal'),modalTitle=document.querySelector('#cond-modal-title'),modalBody=document.querySelector('#cond-modal-body');
function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}
async function linksPorFuncionario(){const mapa=new Map();await Promise.all(condominios.map(async c=>{try{const d=await Api.get(`/api/condominios/${c.id}/funcionarios-vinculados`);(d.funcionarios||[]).forEach(f=>{const fid=Number(f.funcionario_id||f.id);if(!mapa.has(fid))mapa.set(fid,[]);mapa.get(fid).push({id:Number(c.id),nome:c.nome});});}catch(e){}}));return mapa;}
async function refreshLinks(){funcLinks=await linksPorFuncionario()}
async function loadFuncionarios(){if(!Auth.isAdminEmpresa())return;funcPanel.classList.remove('hidden');try{if(!condominios.length)await load();const d=await Api.get('/api/funcionarios');funcionarios=d.funcionarios||[];await refreshLinks();funcRows.innerHTML=funcionarios.map(f=>{const acao=f.cargo==='admin_empresa'?'<span class="muted">Acesso total</span>':'<button class="btn secondary" type="button" onclick="abrirModalCondominios('+f.id+')">Condominios</button>';const opcoes=f.cargo==='admin_empresa'?'<span class="muted">-</span>':'<button class="btn secondary" type="button" onclick="abrirEditarFuncionario('+f.id+')">Editar</button> <button class="btn danger" type="button" onclick="abrirExcluirFuncionario('+f.id+')">Excluir</button>';return '<tr><td>'+esc(f.nome)+'</td><td>'+esc(f.email)+'</td><td>'+esc(f.telefone||'')+'</td><td>'+esc(f.cargo||'')+'</td><td>'+Util.badge(f.status)+'</td><td>'+acao+'</td><td>'+opcoes+'</td></tr>'}).join('')||'<tr><td colspan="7">Nenhum funcionario cadastrado.</td></tr>'}catch(e){Util.toast(e.message,true)}}
if(funcForm){funcForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/funcionarios',Util.formData(funcForm));funcForm.reset();Util.toast('Funcionario cadastrado');loadFuncionarios()}catch(err){Util.toast(err.message,true)}}}
function funcionarioPorId(funcId){return funcionarios.find(x=>Number(x.id)===Number(funcId))}
window.abrirEditarFuncionario=funcId=>{const f=funcionarioPorId(funcId);if(!f)return;modalTitle.textContent='Editar funcionario';modalBody.innerHTML='<form id="func-edit-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required value="'+esc(f.nome||'')+'"></div><div class="field"><label>E-mail de login</label><input name="email" type="email" required value="'+esc(f.email||'')+'"></div><div class="field"><label>Telefone/WhatsApp</label><input name="telefone" value="'+esc(f.telefone||'')+'"></div><div class="field"><label>Status</label><select name="status"><option value="ativo" '+(f.status==='ativo'?'selected':'')+'>Ativo</option><option value="inativo" '+(f.status==='inativo'?'selected':'')+'>Inativo</option></select></div><div class="field"><label>Nova senha</label><input name="senha" type="password"></div><div class="row"><button class="btn">Salvar</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div></form>';modal.classList.remove('hidden');document.querySelector('#func-edit-form').onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/funcionarios/'+funcId,Util.formData(e.target));Util.toast('Funcionario atualizado');fecharModalCondominios();loadFuncionarios()}catch(err){Util.toast(err.message,true)}}};
window.abrirExcluirFuncionario=funcId=>{const f=funcionarioPorId(funcId);if(!f)return;modalTitle.textContent='Excluir funcionario';modalBody.innerHTML='<p>Deseja realmente excluir o acesso de <strong>'+esc(f.nome||'')+'</strong>?</p><p class="muted">O funcionario sera inativado e perdera acesso ao sistema.</p><div class="row"><button class="btn danger" type="button" onclick="confirmarExcluirFuncionario('+funcId+')">Excluir</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div>';modal.classList.remove('hidden')};
window.confirmarExcluirFuncionario=async funcId=>{try{await Api.request('/api/funcionarios/'+funcId,{method:'DELETE'});Util.toast('Funcionario inativado');fecharModalCondominios();loadFuncionarios()}catch(err){Util.toast(err.message,true)}};
function renderModalCondominios(){const f=funcionarios.find(x=>Number(x.id)===Number(funcAtual));if(!f)return;const vinculados=funcLinks.get(Number(funcAtual))||[];const vinculadosIds=new Set(vinculados.map(c=>Number(c.id)));const disponiveis=condominios.filter(c=>!vinculadosIds.has(Number(c.id)));modalTitle.textContent=`Condominios de ${f.nome}`;const listaVinculados=vinculados.map(c=>`<div class="cond-item"><span>${esc(c.nome)}</span><button class="btn danger" type="button" onclick="desvincularFuncionarioCondominio(${funcAtual},${c.id})">Desvincular</button></div>`).join('')||'<p class="muted">Nenhum condominio vinculado.</p>';const listaDisponiveis=disponiveis.map(c=>`<div class="cond-item"><span>${esc(c.nome)}</span><button class="btn secondary" type="button" onclick="vincularFuncionarioCondominio(${funcAtual},${c.id})">Vincular</button></div>`).join('')||'<p class="muted">Todos os condominios ja estao vinculados.</p>';modalBody.innerHTML=`<div class="modal-grid"><section><h3>Vinculados</h3>${listaVinculados}</section><section><h3>Nao vinculados</h3>${listaDisponiveis}</section></div>`}
window.abrirModalCondominios=async funcId=>{funcAtual=Number(funcId);try{await refreshLinks();renderModalCondominios();modal.classList.remove('hidden')}catch(err){Util.toast(err.message,true)}};
window.fecharModalCondominios=()=>{modal.classList.add('hidden');funcAtual=null};
window.vincularFuncionarioCondominio=async (funcId,condId)=>{try{await Api.post(`/api/condominios/${condId}/funcionarios-vinculados`,{funcionario_id:funcId});Util.toast('Funcionario vinculado');await refreshLinks();renderModalCondominios()}catch(err){Util.toast(err.message,true)}};
window.desvincularFuncionarioCondominio=async (funcId,condId)=>{try{await Api.request(`/api/condominios/${condId}/funcionarios-vinculados/${funcId}`,{method:'DELETE'});Util.toast('Funcionario desvinculado');await refreshLinks();renderModalCondominios()}catch(err){Util.toast(err.message,true)}};
loadFuncionarios();
async function loadExtras(){if(!Auth.isAdminEmpresa())return;extraPanel.classList.remove('hidden');try{const d=await Api.get('/api/extras');extras=d.extras||[];extraRows.innerHTML=extras.map(x=>'<tr><td>'+esc(x.nome)+'</td><td>'+esc(x.email||'')+'</td><td>'+esc(x.telefone||'')+'</td><td>'+Util.badge(x.status)+'</td><td><button class="btn secondary" type="button" onclick="abrirEditarExtra('+x.id+')">Editar</button> <button class="btn danger" type="button" onclick="excluirExtra('+x.id+')">Excluir</button></td></tr>').join('')||'<tr><td colspan="5">Nenhum destinatario extra cadastrado.</td></tr>'}catch(e){Util.toast(e.message,true)}}
if(extraForm){extraForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/extras',Util.formData(extraForm));extraForm.reset();Util.toast('Extra cadastrado');loadExtras()}catch(err){Util.toast(err.message,true)}}}
function extraPorId(id){return extras.find(x=>Number(x.id)===Number(id))}
window.abrirEditarExtra=id=>{const x=extraPorId(id);if(!x)return;modalTitle.textContent='Editar extra';modalBody.innerHTML='<form id="extra-edit-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required value="'+esc(x.nome||'')+'"></div><div class="field"><label>E-mail</label><input name="email" type="email" value="'+esc(x.email||'')+'"></div><div class="field"><label>WhatsApp</label><input name="telefone" value="'+esc(x.telefone||'')+'"></div><div class="field"><label>Status</label><select name="status"><option value="ativo" '+(x.status==='ativo'?'selected':'')+'>Ativo</option><option value="inativo" '+(x.status==='inativo'?'selected':'')+'>Inativo</option></select></div><div class="row"><button class="btn">Salvar</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div></form>';modal.classList.remove('hidden');document.querySelector('#extra-edit-form').onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/extras/'+id,Util.formData(e.target));Util.toast('Extra atualizado');fecharModalCondominios();loadExtras()}catch(err){Util.toast(err.message,true)}}};
window.excluirExtra=async id=>{try{await Api.request('/api/extras/'+id,{method:'DELETE'});Util.toast('Extra excluido');loadExtras()}catch(err){Util.toast(err.message,true)}};
loadExtras();
@@ -0,0 +1 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WhatsApp</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>WhatsApp</h1></div><section class="panel"><h2>WhatsApp</h2><form id="wa-form" class="row"><div class="field"><label>Numero WhatsApp</label><input name="numero_whatsapp" required placeholder="5511999999999"></div><button class="btn">Cadastrar</button></form></section><section class="panel"><h2>Status</h2><div id="status"></div><div class="row"><button id="btn-qr" class="btn secondary" type="button">Conectar via QR Code</button><button id="btn-reconnect" class="btn secondary" type="button">Reconectar</button><button id="btn-disconnect" class="btn danger" type="button">Desconectar</button></div><div id="qr-box" class="hidden"><h2>QR Code</h2><img id="qr" class="qr hidden" alt="QR Code Evolution"><div class="field"><label>Codigo de pareamento</label><input id="pairing" readonly></div></div></section></main><script>Layout.mount('empresa','configuracoes');</script><script src="/cliente/configuracoes.js"></script></body></html>

Some files were not shown because too many files have changed in this diff Show More