From c435bb5e23ecd6d3451337b9703d73c74feff12c Mon Sep 17 00:00:00 2001 From: Tiago Date: Tue, 29 Sep 2026 12:12:31 -0300 Subject: [PATCH] Initial project import --- .dockerignore | 5 + .env.example | 19 + .env.production.example | 19 + .gitignore | 9 + Caddyfile | 11 + DEPLOY.md | 89 +++ Dockerfile | 11 + README.md | 49 ++ ...ema-condominio-contabilidade-v3.0-FINAL.md | 215 ++++++++ Schemas/01-setup-projeto-docker.md | 115 ++++ Schemas/02-schema-master.md | 82 +++ Schemas/03-schema-tenant.md | 67 +++ Schemas/04-auth-jwt-apikey.md | 60 ++ Schemas/05-master-clientes-planos.md | 86 +++ Schemas/06-cliente-cadastro-condominio.md | 93 ++++ Schemas/07-financeiro-abacatepay.md | 63 +++ Schemas/08-integracao-evolution-email.md | 87 +++ Schemas/09-frontend-base.md | 75 +++ Schemas/10-tracker-progresso.md | 37 ++ docker-compose.prod.yml | 124 +++++ docker-compose.yml | 98 ++++ .../postgres/init/01-create-evolution-db.sql | 2 + pom.xml | 95 ++++ .../java/com/seconectai/condocontab/Main.java | 111 ++++ .../condocontab/auth/ApiKeyService.java | 55 ++ .../condocontab/auth/JwtService.java | 108 ++++ .../condocontab/auth/PasswordHasher.java | 19 + .../condocontab/auth/RateLimiter.java | 39 ++ .../condocontab/auth/http/AuthHandlers.java | 117 ++++ .../condocontab/auth/http/AuthMiddleware.java | 105 ++++ .../condocontab/config/AppConfig.java | 112 ++++ .../config/DataSourceProvider.java | 43 ++ .../condocontab/db/MigrationRunner.java | 153 +++++ .../financeiro/AbacatePayClient.java | 88 +++ .../financeiro/AbacatePayWebhookHandler.java | 3 + .../condocontab/financeiro/BloqueioJob.java | 96 ++++ .../financeiro/FinanceiroHandlers.java | 3 + .../condocontab/http/HttpResponses.java | 31 ++ .../seconectai/condocontab/http/Router.java | 59 ++ .../condocontab/http/StaticFileHandler.java | 65 +++ .../condocontab/integracao/EmailClient.java | 36 ++ .../integracao/EvolutionApiClient.java | 219 ++++++++ .../condocontab/master/dao/EmpresaDao.java | 82 +++ .../master/dao/FuncionalidadeDao.java | 49 ++ .../master/dao/FuncionarioDao.java | 88 +++ .../master/http/MasterHandlers.java | 519 +++++++++++++++++ .../condocontab/master/model/Empresa.java | 14 + .../master/model/Funcionalidade.java | 9 + .../condocontab/master/model/Funcionario.java | 16 + .../tenant/TenantDataSourceProvider.java | 77 +++ .../condocontab/tenant/dao/CondominioDao.java | 61 ++ .../tenant/dao/ContatoCondominioDao.java | 90 +++ .../condocontab/tenant/dao/ContatoDao.java | 47 ++ .../tenant/http/ClienteHandlers.java | 521 ++++++++++++++++++ .../condocontab/tenant/model/Condominio.java | 6 + .../condocontab/tenant/model/Contato.java | 6 + .../tenant/model/ContatoCondominio.java | 16 + .../provisioning/TenantProvisioner.java | 83 +++ .../com/seconectai/condocontab/util/Json.java | 65 +++ .../condocontab/util/SimpleJson.java | 116 ++++ .../migrations/master/001_empresas.sql | 9 + .../migrations/master/002_funcionarios.sql | 12 + .../migrations/master/003_api_keys.sql | 11 + .../migrations/master/004_funcionalidades.sql | 6 + .../master/005_empresa_funcionalidades.sql | 8 + .../master/006_evolution_instancias.sql | 13 + .../master/007_configuracoes_plataforma.sql | 5 + .../migrations/master/008_planos.sql | 8 + .../master/009_plano_funcionalidades.sql | 5 + .../migrations/master/010_assinaturas.sql | 11 + .../migrations/master/011_periodos_uso.sql | 11 + .../migrations/master/012_pagamentos.sql | 15 + .../master/013_logs_envio_avisos.sql | 14 + .../master/014_lembretes_cobranca.sql | 8 + .../master/015_seed_funcionalidades.sql | 32 ++ .../master/016_evolution_numero_unico.sql | 16 + .../master/017_funcionarios_telefone.sql | 1 + .../master/018_periodos_mes_fechado.sql | 37 ++ .../migrations/tenant/001_condominios.sql | 7 + .../migrations/tenant/002_contatos.sql | 7 + .../tenant/003_contato_condominio.sql | 9 + .../tenant/004_funcionario_condominio.sql | 5 + .../migrations/tenant/005_espacos_comuns.sql | 10 + .../migrations/tenant/006_assembleias.sql | 10 + .../migrations/tenant/007_documentos.sql | 10 + .../migrations/tenant/008_avisos.sql | 17 + .../tenant/009_aviso_documentos.sql | 5 + .../tenant/010_aviso_destinatarios.sql | 6 + .../migrations/tenant/011_log_auditoria.sql | 10 + .../migrations/tenant/012_indices.sql | 9 + .../013_condominios_cnpj_obrigatorio.sql | 2 + .../migrations/tenant/014_lembretes.sql | 19 + .../migrations/tenant/015_fila_envio.sql | 30 + .../016_recorrencia_lembretes_avisos.sql | 10 + .../tenant/017_destinatarios_extra.sql | 11 + .../webapp/cliente/condominio-detalhe.html | 1 + src/main/webapp/cliente/condominio-detalhe.js | 30 + src/main/webapp/cliente/condominios.html | 1 + src/main/webapp/cliente/condominios.js | 29 + src/main/webapp/cliente/configuracoes.html | 1 + src/main/webapp/cliente/configuracoes.js | 1 + src/main/webapp/cliente/financeiro.html | 1 + src/main/webapp/cliente/financeiro.js | 7 + src/main/webapp/cliente/lembretes.html | 1 + src/main/webapp/cliente/lembretes.js | 20 + src/main/webapp/css/app.css | 4 + src/main/webapp/index.html | 1 + src/main/webapp/js/api.js | 1 + src/main/webapp/js/auth.js | 1 + src/main/webapp/js/layout.js | 1 + src/main/webapp/js/util.js | 1 + src/main/webapp/login.html | 1 + src/main/webapp/master/clientes.html | 1 + src/main/webapp/master/clientes.js | 18 + src/main/webapp/master/configuracoes.html | 1 + src/main/webapp/master/configuracoes.js | 1 + src/main/webapp/master/financeiro.html | 1 + src/main/webapp/master/financeiro.js | 8 + src/main/webapp/master/planos.html | 1 + src/main/webapp/master/planos.js | 1 + 120 files changed, 5371 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 Schemas/000 - arquitetura-schema-condominio-contabilidade-v3.0-FINAL.md create mode 100644 Schemas/01-setup-projeto-docker.md create mode 100644 Schemas/02-schema-master.md create mode 100644 Schemas/03-schema-tenant.md create mode 100644 Schemas/04-auth-jwt-apikey.md create mode 100644 Schemas/05-master-clientes-planos.md create mode 100644 Schemas/06-cliente-cadastro-condominio.md create mode 100644 Schemas/07-financeiro-abacatepay.md create mode 100644 Schemas/08-integracao-evolution-email.md create mode 100644 Schemas/09-frontend-base.md create mode 100644 Schemas/10-tracker-progresso.md create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/postgres/init/01-create-evolution-db.sql create mode 100644 pom.xml create mode 100644 src/main/java/com/seconectai/condocontab/Main.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/ApiKeyService.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/JwtService.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/PasswordHasher.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/RateLimiter.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/http/AuthHandlers.java create mode 100644 src/main/java/com/seconectai/condocontab/auth/http/AuthMiddleware.java create mode 100644 src/main/java/com/seconectai/condocontab/config/AppConfig.java create mode 100644 src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java create mode 100644 src/main/java/com/seconectai/condocontab/db/MigrationRunner.java create mode 100644 src/main/java/com/seconectai/condocontab/financeiro/AbacatePayClient.java create mode 100644 src/main/java/com/seconectai/condocontab/financeiro/AbacatePayWebhookHandler.java create mode 100644 src/main/java/com/seconectai/condocontab/financeiro/BloqueioJob.java create mode 100644 src/main/java/com/seconectai/condocontab/financeiro/FinanceiroHandlers.java create mode 100644 src/main/java/com/seconectai/condocontab/http/HttpResponses.java create mode 100644 src/main/java/com/seconectai/condocontab/http/Router.java create mode 100644 src/main/java/com/seconectai/condocontab/http/StaticFileHandler.java create mode 100644 src/main/java/com/seconectai/condocontab/integracao/EmailClient.java create mode 100644 src/main/java/com/seconectai/condocontab/integracao/EvolutionApiClient.java create mode 100644 src/main/java/com/seconectai/condocontab/master/dao/EmpresaDao.java create mode 100644 src/main/java/com/seconectai/condocontab/master/dao/FuncionalidadeDao.java create mode 100644 src/main/java/com/seconectai/condocontab/master/dao/FuncionarioDao.java create mode 100644 src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java create mode 100644 src/main/java/com/seconectai/condocontab/master/model/Empresa.java create mode 100644 src/main/java/com/seconectai/condocontab/master/model/Funcionalidade.java create mode 100644 src/main/java/com/seconectai/condocontab/master/model/Funcionario.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/TenantDataSourceProvider.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/dao/CondominioDao.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/dao/ContatoCondominioDao.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/dao/ContatoDao.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/model/Condominio.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/model/Contato.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/model/ContatoCondominio.java create mode 100644 src/main/java/com/seconectai/condocontab/tenant/provisioning/TenantProvisioner.java create mode 100644 src/main/java/com/seconectai/condocontab/util/Json.java create mode 100644 src/main/java/com/seconectai/condocontab/util/SimpleJson.java create mode 100644 src/main/resources/migrations/master/001_empresas.sql create mode 100644 src/main/resources/migrations/master/002_funcionarios.sql create mode 100644 src/main/resources/migrations/master/003_api_keys.sql create mode 100644 src/main/resources/migrations/master/004_funcionalidades.sql create mode 100644 src/main/resources/migrations/master/005_empresa_funcionalidades.sql create mode 100644 src/main/resources/migrations/master/006_evolution_instancias.sql create mode 100644 src/main/resources/migrations/master/007_configuracoes_plataforma.sql create mode 100644 src/main/resources/migrations/master/008_planos.sql create mode 100644 src/main/resources/migrations/master/009_plano_funcionalidades.sql create mode 100644 src/main/resources/migrations/master/010_assinaturas.sql create mode 100644 src/main/resources/migrations/master/011_periodos_uso.sql create mode 100644 src/main/resources/migrations/master/012_pagamentos.sql create mode 100644 src/main/resources/migrations/master/013_logs_envio_avisos.sql create mode 100644 src/main/resources/migrations/master/014_lembretes_cobranca.sql create mode 100644 src/main/resources/migrations/master/015_seed_funcionalidades.sql create mode 100644 src/main/resources/migrations/master/016_evolution_numero_unico.sql create mode 100644 src/main/resources/migrations/master/017_funcionarios_telefone.sql create mode 100644 src/main/resources/migrations/master/018_periodos_mes_fechado.sql create mode 100644 src/main/resources/migrations/tenant/001_condominios.sql create mode 100644 src/main/resources/migrations/tenant/002_contatos.sql create mode 100644 src/main/resources/migrations/tenant/003_contato_condominio.sql create mode 100644 src/main/resources/migrations/tenant/004_funcionario_condominio.sql create mode 100644 src/main/resources/migrations/tenant/005_espacos_comuns.sql create mode 100644 src/main/resources/migrations/tenant/006_assembleias.sql create mode 100644 src/main/resources/migrations/tenant/007_documentos.sql create mode 100644 src/main/resources/migrations/tenant/008_avisos.sql create mode 100644 src/main/resources/migrations/tenant/009_aviso_documentos.sql create mode 100644 src/main/resources/migrations/tenant/010_aviso_destinatarios.sql create mode 100644 src/main/resources/migrations/tenant/011_log_auditoria.sql create mode 100644 src/main/resources/migrations/tenant/012_indices.sql create mode 100644 src/main/resources/migrations/tenant/013_condominios_cnpj_obrigatorio.sql create mode 100644 src/main/resources/migrations/tenant/014_lembretes.sql create mode 100644 src/main/resources/migrations/tenant/015_fila_envio.sql create mode 100644 src/main/resources/migrations/tenant/016_recorrencia_lembretes_avisos.sql create mode 100644 src/main/resources/migrations/tenant/017_destinatarios_extra.sql create mode 100644 src/main/webapp/cliente/condominio-detalhe.html create mode 100644 src/main/webapp/cliente/condominio-detalhe.js create mode 100644 src/main/webapp/cliente/condominios.html create mode 100644 src/main/webapp/cliente/condominios.js create mode 100644 src/main/webapp/cliente/configuracoes.html create mode 100644 src/main/webapp/cliente/configuracoes.js create mode 100644 src/main/webapp/cliente/financeiro.html create mode 100644 src/main/webapp/cliente/financeiro.js create mode 100644 src/main/webapp/cliente/lembretes.html create mode 100644 src/main/webapp/cliente/lembretes.js create mode 100644 src/main/webapp/css/app.css create mode 100644 src/main/webapp/index.html create mode 100644 src/main/webapp/js/api.js create mode 100644 src/main/webapp/js/auth.js create mode 100644 src/main/webapp/js/layout.js create mode 100644 src/main/webapp/js/util.js create mode 100644 src/main/webapp/login.html create mode 100644 src/main/webapp/master/clientes.html create mode 100644 src/main/webapp/master/clientes.js create mode 100644 src/main/webapp/master/configuracoes.html create mode 100644 src/main/webapp/master/configuracoes.js create mode 100644 src/main/webapp/master/financeiro.html create mode 100644 src/main/webapp/master/financeiro.js create mode 100644 src/main/webapp/master/planos.html create mode 100644 src/main/webapp/master/planos.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38fe920 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.m2/ +target/ +.git/ +.env +uploads/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3cd383e --- /dev/null +++ b/.env.example @@ -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= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..9b661a9 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7872d20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.m2/ +target/ +.env +uploads/ +.env.*.local +*.log +.DS_Store +.idea/ +.vscode/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..c7c48af --- /dev/null +++ b/Caddyfile @@ -0,0 +1,11 @@ +condominio.seconectai.com.br { + encode gzip zstd + + handle_path /evolution/* { + reverse_proxy evolution-api:8080 + } + + handle { + reverse_proxy app:8080 + } +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..0dc1a58 --- /dev/null +++ b/DEPLOY.md @@ -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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..146e596 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..489fc6c --- /dev/null +++ b/README.md @@ -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`. diff --git a/Schemas/000 - arquitetura-schema-condominio-contabilidade-v3.0-FINAL.md b/Schemas/000 - arquitetura-schema-condominio-contabilidade-v3.0-FINAL.md new file mode 100644 index 0000000..a178017 --- /dev/null +++ b/Schemas/000 - arquitetura-schema-condominio-contabilidade-v3.0-FINAL.md @@ -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_` (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)? diff --git a/Schemas/01-setup-projeto-docker.md b/Schemas/01-setup-projeto-docker.md new file mode 100644 index 0000000..20f8a7f --- /dev/null +++ b/Schemas/01-setup-projeto-docker.md @@ -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` 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_` (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` 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)` 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. diff --git a/Schemas/02-schema-master.md b/Schemas/02-schema-master.md new file mode 100644 index 0000000..dc61672 --- /dev/null +++ b/Schemas/02-schema-master.md @@ -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. diff --git a/Schemas/03-schema-tenant.md b/Schemas/03-schema-tenant.md new file mode 100644 index 0000000..b46e05f --- /dev/null +++ b/Schemas/03-schema-tenant.md @@ -0,0 +1,67 @@ +# Prompt 03 — Migrations do `db_empresa_` (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_`, (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_` (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` (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_` (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. diff --git a/Schemas/04-auth-jwt-apikey.md b/Schemas/04-auth-jwt-apikey.md new file mode 100644 index 0000000..2ec5e7a --- /dev/null +++ b/Schemas/04-auth-jwt-apikey.md @@ -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 claims, long validadeSegundos)` → string do token. +- `JwtService.validar(String token)` → `Optional> 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 `, 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. diff --git a/Schemas/05-master-clientes-planos.md b/Schemas/05-master-clientes-planos.md new file mode 100644 index 0000000..efa0685 --- /dev/null +++ b/Schemas/05-master-clientes-planos.md @@ -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=) +``` +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_` (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. diff --git a/Schemas/06-cliente-cadastro-condominio.md b/Schemas/06-cliente-cadastro-condominio.md new file mode 100644 index 0000000..e6e3b8e --- /dev/null +++ b/Schemas/06-cliente-cadastro-condominio.md @@ -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_` 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/ + //-), 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. diff --git a/Schemas/07-financeiro-abacatepay.md b/Schemas/07-financeiro-abacatepay.md new file mode 100644 index 0000000..1cd568b --- /dev/null +++ b/Schemas/07-financeiro-abacatepay.md @@ -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. diff --git a/Schemas/08-integracao-evolution-email.md b/Schemas/08-integracao-evolution-email.md new file mode 100644 index 0000000..0fee333 --- /dev/null +++ b/Schemas/08-integracao-evolution-email.md @@ -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. diff --git a/Schemas/09-frontend-base.md b/Schemas/09-frontend-base.md new file mode 100644 index 0000000..626cb59 --- /dev/null +++ b/Schemas/09-frontend-base.md @@ -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 , +│ 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 (``), 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 ``) + 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. diff --git a/Schemas/10-tracker-progresso.md b/Schemas/10-tracker-progresso.md new file mode 100644 index 0000000..3278866 --- /dev/null +++ b/Schemas/10-tracker-progresso.md @@ -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_`). +- 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. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bc688c0 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a73da0b --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/postgres/init/01-create-evolution-db.sql b/docker/postgres/init/01-create-evolution-db.sql new file mode 100644 index 0000000..bde6ecd --- /dev/null +++ b/docker/postgres/init/01-create-evolution-db.sql @@ -0,0 +1,2 @@ +SELECT 'CREATE DATABASE evolution' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'evolution')\gexec diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f9a7ac4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,95 @@ + + 4.0.0 + + com.seconectai + condocontab + 0.1.0 + SeConectai Condominios + + + 21 + 21 + UTF-8 + + + + + org.postgresql + postgresql + 42.7.4 + + + at.favre.lib + bcrypt + 0.10.2 + + + com.sun.mail + jakarta.mail + 2.0.1 + + + com.google.zxing + core + 3.5.3 + + + com.google.zxing + javase + 3.5.3 + + + + + + + + src/main/resources + + + src/main/webapp + webapp + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + com.seconectai.condocontab.Main + + + + + + + + + diff --git a/src/main/java/com/seconectai/condocontab/Main.java b/src/main/java/com/seconectai/condocontab/Main.java new file mode 100644 index 0000000..e6687ab --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/Main.java @@ -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")); + } + +} diff --git a/src/main/java/com/seconectai/condocontab/auth/ApiKeyService.java b/src/main/java/com/seconectai/condocontab/auth/ApiKeyService.java new file mode 100644 index 0000000..5524146 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/ApiKeyService.java @@ -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) { + } +} diff --git a/src/main/java/com/seconectai/condocontab/auth/JwtService.java b/src/main/java/com/seconectai/condocontab/auth/JwtService.java new file mode 100644 index 0000000..143d8e1 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/JwtService.java @@ -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 claims, long validadeSegundos) { + Map header = Map.of("alg", "HS256", "typ", "JWT"); + Map 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> 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 parsed = SimpleJson.parseObject(payloadJson); + long exp = Long.parseLong(parsed.getOrDefault("exp", "0")); + if (Instant.now().getEpochSecond() >= exp) { + return Optional.empty(); + } + Map 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; + } +} diff --git a/src/main/java/com/seconectai/condocontab/auth/PasswordHasher.java b/src/main/java/com/seconectai/condocontab/auth/PasswordHasher.java new file mode 100644 index 0000000..21e886c --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/PasswordHasher.java @@ -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; + } +} diff --git a/src/main/java/com/seconectai/condocontab/auth/RateLimiter.java b/src/main/java/com/seconectai/condocontab/auth/RateLimiter.java new file mode 100644 index 0000000..f182043 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/RateLimiter.java @@ -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> 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 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; + } + } +} diff --git a/src/main/java/com/seconectai/condocontab/auth/http/AuthHandlers.java b/src/main/java/com/seconectai/condocontab/auth/http/AuthHandlers.java new file mode 100644 index 0000000..e4a56aa --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/http/AuthHandlers.java @@ -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 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 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 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 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(); + } +} diff --git a/src/main/java/com/seconectai/condocontab/auth/http/AuthMiddleware.java b/src/main/java/com/seconectai/condocontab/auth/http/AuthMiddleware.java new file mode 100644 index 0000000..06ea4e0 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/auth/http/AuthMiddleware.java @@ -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 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)); + } +} diff --git a/src/main/java/com/seconectai/condocontab/config/AppConfig.java b/src/main/java/com/seconectai/condocontab/config/AppConfig.java new file mode 100644 index 0000000..865a4c0 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/config/AppConfig.java @@ -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 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 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 env, String name, String defaultValue) { + String value = env.get(name); + return value == null || value.isBlank() ? defaultValue : value; + } + + private static int intValue(Map 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; } +} diff --git a/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java b/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java new file mode 100644 index 0000000..62f3720 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java @@ -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. +} diff --git a/src/main/java/com/seconectai/condocontab/db/MigrationRunner.java b/src/main/java/com/seconectai/condocontab/db/MigrationRunner.java new file mode 100644 index 0000000..7c88592 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/db/MigrationRunner.java @@ -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 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 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 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 listarMigrationsNoJar(String diretorioResources) throws IOException { + List 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 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(); + } + } +} diff --git a/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayClient.java b/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayClient.java new file mode 100644 index 0000000..823617d --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayClient.java @@ -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 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) {} +} diff --git a/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayWebhookHandler.java b/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayWebhookHandler.java new file mode 100644 index 0000000..f029da8 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/financeiro/AbacatePayWebhookHandler.java @@ -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 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{Mapm=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 Mapquery(HttpExchange ex){Mapq=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 Mapone(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;im=new HashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}}}} diff --git a/src/main/java/com/seconectai/condocontab/financeiro/BloqueioJob.java b/src/main/java/com/seconectai/condocontab/financeiro/BloqueioJob.java new file mode 100644 index 0000000..dd34d75 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/financeiro/BloqueioJob.java @@ -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) {} +} diff --git a/src/main/java/com/seconectai/condocontab/financeiro/FinanceiroHandlers.java b/src/main/java/com/seconectai/condocontab/financeiro/FinanceiroHandlers.java new file mode 100644 index 0000000..f7d95d8 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/financeiro/FinanceiroHandlers.java @@ -0,0 +1,3 @@ +package com.seconectai.condocontab.financeiro; +import com.seconectai.condocontab.auth.http.AuthMiddleware;import com.seconectai.condocontab.config.DataSourceProvider;import com.seconectai.condocontab.http.HttpResponses;import com.sun.net.httpserver.*;import java.io.IOException;import java.sql.*;import java.time.LocalDate;import java.util.*; +public final class FinanceiroHandlers implements HttpHandler{private final DataSourceProvider ds;private final AbacatePayClient abacate;public FinanceiroHandlers(DataSourceProvider ds,AbacatePayClient abacate){this.ds=ds;this.abacate=abacate;}@Override public void handle(HttpExchange ex)throws IOException{try{route(ex);}catch(Exception e){e.printStackTrace(System.err);HttpResponses.json(ex,500,Map.of("erro","erro_financeiro","mensagem",String.valueOf(e.getMessage())));}}private void route(HttpExchange ex)throws Exception{String path=ex.getRequestURI().getPath();String method=ex.getRequestMethod();long emp=(long)ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID);if(path.equals("/api/financeiro/periodo-atual")&&"GET".equals(method)){periodoAtual(ex,emp);return;}if(path.equals("/api/financeiro/pagamentos")&&"POST".equals(method)){criarPagamento(ex,emp);return;}if(path.startsWith("/api/financeiro/pagamentos/")&&"GET".equals(method)){statusPagamento(ex,emp,Long.parseLong(path.substring(path.lastIndexOf('/')+1)));return;}HttpResponses.json(ex,404,Map.of("erro","endpoint_nao_encontrado"));}private void periodoAtual(HttpExchange ex,long emp)throws Exception{try(Connection c=ds.getMasterConnection()){long per=periodo(c,emp);Map ass=assinatura(c,emp);Map pag=one(c,"SELECT * FROM pagamentos WHERE empresa_id=? AND periodo_uso_id=? ORDER BY id DESC LIMIT 1",emp,per);Map p=one(c,"SELECT * FROM periodos_uso WHERE id=?",per);Map out=new LinkedHashMap<>();out.put("periodo",p);out.put("assinatura",ass);out.put("pagamento",pag);HttpResponses.json(ex,200,out);}}private void criarPagamento(HttpExchange ex,long emp)throws Exception{try(Connection c=ds.getMasterConnection()){long per=periodo(c,emp);Map pend=one(c,"SELECT * FROM pagamentos WHERE empresa_id=? AND periodo_uso_id=? AND status='pendente' ORDER BY id DESC LIMIT 1",emp,per);if(pend!=null){HttpResponses.json(ex,200,Map.of("pagamento",pend));return;}Map ass=assinatura(c,emp);long valor=((Number)ass.get("valor_mensal_centavos")).longValue();String ext="periodo_"+per+"_empresa_"+emp;var cob=abacate.criarCobranca(valor,"Mensalidade SeConectai",ext);try(PreparedStatement ps=c.prepareStatement("INSERT INTO pagamentos (empresa_id,periodo_uso_id,valor_centavos,abacatepay_charge_id,qr_code_payload,qr_code_base64,status) VALUES (?,?,?,?,?,?,'pendente') RETURNING id")){ps.setLong(1,emp);ps.setLong(2,per);ps.setLong(3,valor);ps.setString(4,cob.chargeId());ps.setString(5,cob.qrCodePayload());ps.setString(6,cob.qrCodeBase64());try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("pagamento",one(c,"SELECT * FROM pagamentos WHERE id=?",rs.getLong(1))));}}}}private void statusPagamento(HttpExchange ex,long emp,long id)throws Exception{try(Connection c=ds.getMasterConnection()){Map p=one(c,"SELECT * FROM pagamentos WHERE id=? AND empresa_id=?",id,emp);if(p==null){HttpResponses.json(ex,404,Map.of("erro","pagamento_nao_encontrado"));return;}HttpResponses.json(ex,200,Map.of("pagamento",p));}}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{Mapa=one(c,"SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",emp);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 Map assinatura(Connection c,long emp)throws SQLException{Mapa=one(c,"SELECT * FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",emp);if(a!=null)return a;try(PreparedStatement ps=c.prepareStatement("INSERT INTO assinaturas (empresa_id,plano_id,valor_mensal_centavos,dia_vencimento,status) VALUES (?,COALESCE((SELECT id FROM planos ORDER BY id LIMIT 1),1),10000,7,'ativa') RETURNING *")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){rs.next();return map(rs);}}}Map one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i map(ResultSet rs)throws SQLException{Mapm=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}} diff --git a/src/main/java/com/seconectai/condocontab/http/HttpResponses.java b/src/main/java/com/seconectai/condocontab/http/HttpResponses.java new file mode 100644 index 0000000..adcb741 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/http/HttpResponses.java @@ -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 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); + } +} diff --git a/src/main/java/com/seconectai/condocontab/http/Router.java b/src/main/java/com/seconectai/condocontab/http/Router.java new file mode 100644 index 0000000..8a7aff8 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/http/Router.java @@ -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 exactRoutes = new LinkedHashMap<>(); + private final Map 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 entry : prefixRoutes.entrySet()) { + if (path.startsWith(entry.getKey())) { + entry.getValue().handle(exchange); + return; + } + } + + if (fallbackHandler != null) { + fallbackHandler.handle(exchange); + return; + } + + exchange.sendResponseHeaders(404, -1); + } +} diff --git a/src/main/java/com/seconectai/condocontab/http/StaticFileHandler.java b/src/main/java/com/seconectai/condocontab/http/StaticFileHandler.java new file mode 100644 index 0000000..c4c1b85 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/http/StaticFileHandler.java @@ -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 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"); + } +} diff --git a/src/main/java/com/seconectai/condocontab/integracao/EmailClient.java b/src/main/java/com/seconectai/condocontab/integracao/EmailClient.java new file mode 100644 index 0000000..3c0b7bd --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/integracao/EmailClient.java @@ -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 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); + } +} diff --git a/src/main/java/com/seconectai/condocontab/integracao/EvolutionApiClient.java b/src/main/java/com/seconectai/condocontab/integracao/EvolutionApiClient.java new file mode 100644 index 0000000..ca5ec4f --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/integracao/EvolutionApiClient.java @@ -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 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 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 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 resp = sendNoBody("GET", url, globalApiKey); + Map 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 logout(String baseUrl, String instanceName) throws IOException, InterruptedException { + return instanceAction("DELETE", baseUrl, "/instance/logout/" + instanceName, instanceName); + } + + public Map excluirInstancia(String baseUrl, String instanceName) throws IOException, InterruptedException { + return instanceAction("DELETE", baseUrl, "/instance/delete/" + instanceName, instanceName); + } + + public Map reiniciar(String baseUrl, String instanceName) throws IOException, InterruptedException { + return instanceAction("POST", baseUrl, "/instance/restart/" + instanceName, instanceName); + } + + public Map 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 resp = sendNoBody("GET", clean(baseUrl) + "/instance/connectionState/" + instanceName, globalApiKey); + Map 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 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 resp = sendNoBody(method, clean(baseUrl) + path, globalApiKey); + return Map.of("instance_name", instanceName, "ok", true, "raw", resp.body()); + } + + private HttpResponse 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 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 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 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) {} +} diff --git a/src/main/java/com/seconectai/condocontab/master/dao/EmpresaDao.java b/src/main/java/com/seconectai/condocontab/master/dao/EmpresaDao.java new file mode 100644 index 0000000..40175da --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/dao/EmpresaDao.java @@ -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 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 listarTodas() throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas ORDER BY id")) { + try (ResultSet rs = ps.executeQuery()) { + List 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() + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/master/dao/FuncionalidadeDao.java b/src/main/java/com/seconectai/condocontab/master/dao/FuncionalidadeDao.java new file mode 100644 index 0000000..0009ebf --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/dao/FuncionalidadeDao.java @@ -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 listarTodas() throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades ORDER BY chave")) { + try (ResultSet rs = ps.executeQuery()) { + List funcionalidades = new ArrayList<>(); + while (rs.next()) { + funcionalidades.add(map(rs)); + } + return funcionalidades; + } + } + } + + public Optional 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") + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/master/dao/FuncionarioDao.java b/src/main/java/com/seconectai/condocontab/master/dao/FuncionarioDao.java new file mode 100644 index 0000000..f424603 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/dao/FuncionarioDao.java @@ -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 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 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 funcionarios = new ArrayList<>(); + while (rs.next()) { + funcionarios.add(map(rs)); + } + return funcionarios; + } + } + } + + private Optional 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() + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java b/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java new file mode 100644 index 0000000..9d626be --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java @@ -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> 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 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 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 cfg = new LinkedHashMap<>(); + List> 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 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 b = body(ex); + Set 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 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 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 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 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> 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 out = empresaMap(emp.get()); + Map 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 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 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> 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> 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 funcionalidades = stringArray(raw, "funcionalidades"); + Map 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> 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 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 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> 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{ + Mapb=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;} + Mapa=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 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 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{Mapa=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, Mapb)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,Mapb)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 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 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 empresaMap(Empresa e){Map 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 funcionarioMap(Funcionario f){Map 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 empresaResumoMap(ResultSet rs)throws SQLException{Map 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 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 planoMap(ResultSet rs)throws SQLException{Map 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 resultMap(ResultSet rs)throws SQLException{Map 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 one(Connection c,String sql,Listparams)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i 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 query(HttpExchange ex){Map 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 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 params)throws SQLException{for(int i=0;i 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 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);} +} diff --git a/src/main/java/com/seconectai/condocontab/master/model/Empresa.java b/src/main/java/com/seconectai/condocontab/master/model/Empresa.java new file mode 100644 index 0000000..b30ac7c --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/model/Empresa.java @@ -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 +) { +} diff --git a/src/main/java/com/seconectai/condocontab/master/model/Funcionalidade.java b/src/main/java/com/seconectai/condocontab/master/model/Funcionalidade.java new file mode 100644 index 0000000..481ef2e --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/model/Funcionalidade.java @@ -0,0 +1,9 @@ +package com.seconectai.condocontab.master.model; + +public record Funcionalidade( + Long id, + String chave, + String nome, + String descricao +) { +} diff --git a/src/main/java/com/seconectai/condocontab/master/model/Funcionario.java b/src/main/java/com/seconectai/condocontab/master/model/Funcionario.java new file mode 100644 index 0000000..3aad502 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/master/model/Funcionario.java @@ -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 +) { +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/TenantDataSourceProvider.java b/src/main/java/com/seconectai/condocontab/tenant/TenantDataSourceProvider.java new file mode 100644 index 0000000..72a8e23 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/TenantDataSourceProvider.java @@ -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 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. +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/dao/CondominioDao.java b/src/main/java/com/seconectai/condocontab/tenant/dao/CondominioDao.java new file mode 100644 index 0000000..64dc337 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/dao/CondominioDao.java @@ -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 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 listarTodos(Connection conn) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios ORDER BY nome")) { + try (ResultSet rs = ps.executeQuery()) { + List 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() + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoCondominioDao.java b/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoCondominioDao.java new file mode 100644 index 0000000..fdc149a --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoCondominioDao.java @@ -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 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 vinculos = new ArrayList<>(); + while (rs.next()) { + vinculos.add(map(rs)); + } + return vinculos; + } + } + } + + public Optional 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") + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoDao.java b/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoDao.java new file mode 100644 index 0000000..074dba1 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/dao/ContatoDao.java @@ -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 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() + ); + } +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java b/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java new file mode 100644 index 0000000..2a69f6a --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java @@ -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 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{Mapb=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 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 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 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{Mapb=body(ex); Map 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{Mapb=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{Mapb=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> 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(Mapa: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{Mapb=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> links=rows(c,"SELECT * FROM funcionario_condominio WHERE condominio_id=? ORDER BY funcionario_id",Arrays.asList(cond));List> 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 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 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{Mapb=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{Mapb=body(ex); Map 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{ + Mapb=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 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 criarAvisoAssembleia(Connection c,long emp,long cond,long assembleia,long fid,Mapb)throws Exception{ + List> contatosAviso=rows(c,"SELECT contato_id FROM contato_condominio WHERE condominio_id=? AND status=?",Arrays.asList(cond,"ativo")); + List> 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 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 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{Mapb=body(ex); Map 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{Mapd=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{Mapb=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 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;}Mapb=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 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> 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); + Mapb=SimpleJson.parseObject(removeArrayField(raw,"destinatarios")); + Listdests=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 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> 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 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 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> dests=(List>)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 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 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);Mapb=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));Listdests=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 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 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 lembrete,String cargo,long fid){return adminEmpresa(cargo)||((Number)lembrete.get("criado_por_funcionario")).longValue()==fid;} + private Map enviarLembreteWhatsApp(long emp,long lembreteId,Map lembrete)throws Exception{ + Map 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> dests=(List>)lembrete.getOrDefault("destinatarios",List.of()); + int enviados=0, falhas=0; + List detalhes=new ArrayList<>(); + for(Map 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 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,Listdests)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 lembreteComDetalhes(Connection c,long emp,long id)throws Exception{Map 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> destinatariosLembrete(Connection c,long emp,long id)throws Exception{List> ids=rows(c,"SELECT funcionario_id FROM lembrete_destinatarios WHERE lembrete_id=? ORDER BY funcionario_id",Arrays.asList(id));List> 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 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> 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{Mapb=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{ + Mapb=body(ex); + try(Connection m=masterDs.getMasterConnection()){ + Map 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 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 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{Mapb=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 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 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> existentes=rows(m,"SELECT id,instance_url,instance_token_enc FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id",Arrays.asList(numero));for(Map 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 inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,200,Map.of("status","nao_configurada"));return;}Map 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 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 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 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 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 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> out=rows(c,sql,adminEmpresa(cargo)?Arrays.asList():Arrays.asList(fid)); + for(Mapa:out){ + long aid=((Number)a.get("id")).longValue(); + Map 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> destinatariosAviso(Connection c,long emp,long aviso)throws Exception{ + List> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso)); + List> out=new ArrayList<>(); + for(Mapd:dests){ + String tipo=str(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map 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 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);Mapb=SimpleJson.parseObject(removeArrayField(removeArrayField(raw,"documentos"),"destinatarios"));boolean w=bool(b.get("enviar_whatsapp"));boolean e=bool(b.get("enviar_email"));List 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{Mapa=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 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 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{Mapa=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 inst=w?evolutionAprovada(emp):null;Map smtp=e?smtpConfig():Map.of();List> 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(Mapd:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map 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,Mapa)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{Mapa=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 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 smtpConfig()throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT chave,valor FROM configuracoes_plataforma WHERE chave LIKE 'smtp_%'")){Map out=new HashMap<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.put(rs.getString(1),rs.getString(2));}return out;}} + private Map 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(Mapa){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 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 lembrete=lembreteComDetalhes(c,emp,ref); + if(lembrete==null||!"ativo".equalsIgnoreCase(str(lembrete.get("status")))){ + concluirItemFila(c,filaId,"ignorado",null); + }else{ + Map 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 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 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 proximoItemFila(Connection c)throws SQLException{ + c.setAutoCommit(false); + try{ + Map 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> 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 row:pendentes){ + long lembreteId=((Number)row.get("id")).longValue(); + Map lembrete=lembreteComDetalhes(c,emp,lembreteId); + if(lembrete==null)continue; + Map 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 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> 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(Mapa: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,Listparams,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,Listparams)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 one(Connection c,String sql,Listparams)throws SQLException{List> r=rows(c,sql,params);return r.isEmpty()?null:r.get(0);} private List> rows(Connection c,String sql,Listparams)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){List> out=new ArrayList<>();ResultSetMetaData md=rs.getMetaData();while(rs.next()){Map 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 resultMap(ResultSet rs)throws SQLException{Map 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,Listparams)throws SQLException{for(int i=0;i 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(Mapm,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 longArray(String raw,String key){String a=arrayBody(raw,key);if(a.isBlank())return List.of();List 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 destinatarios(String raw){String a=arrayBody(raw,"destinatarios");if(a.isBlank())return List.of();List 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 query(HttpExchange ex){Mapq=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){} +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/model/Condominio.java b/src/main/java/com/seconectai/condocontab/tenant/model/Condominio.java new file mode 100644 index 0000000..b13aaf0 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/model/Condominio.java @@ -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) { +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/model/Contato.java b/src/main/java/com/seconectai/condocontab/tenant/model/Contato.java new file mode 100644 index 0000000..6b8f9ac --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/model/Contato.java @@ -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) { +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/model/ContatoCondominio.java b/src/main/java/com/seconectai/condocontab/tenant/model/ContatoCondominio.java new file mode 100644 index 0000000..7761563 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/model/ContatoCondominio.java @@ -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 +) { +} diff --git a/src/main/java/com/seconectai/condocontab/tenant/provisioning/TenantProvisioner.java b/src/main/java/com/seconectai/condocontab/tenant/provisioning/TenantProvisioner.java new file mode 100644 index 0000000..2b09da2 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/tenant/provisioning/TenantProvisioner.java @@ -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); + } + } +} diff --git a/src/main/java/com/seconectai/condocontab/util/Json.java b/src/main/java/com/seconectai/condocontab/util/Json.java new file mode 100644 index 0000000..23d0cc1 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/util/Json.java @@ -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> 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(); + } +} diff --git a/src/main/java/com/seconectai/condocontab/util/SimpleJson.java b/src/main/java/com/seconectai/condocontab/util/SimpleJson.java new file mode 100644 index 0000000..247e528 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/util/SimpleJson.java @@ -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 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 parseObject() { + Map 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++; + } + } + } +} diff --git a/src/main/resources/migrations/master/001_empresas.sql b/src/main/resources/migrations/master/001_empresas.sql new file mode 100644 index 0000000..c42ba1a --- /dev/null +++ b/src/main/resources/migrations/master/001_empresas.sql @@ -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() +); diff --git a/src/main/resources/migrations/master/002_funcionarios.sql b/src/main/resources/migrations/master/002_funcionarios.sql new file mode 100644 index 0000000..2844d14 --- /dev/null +++ b/src/main/resources/migrations/master/002_funcionarios.sql @@ -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); diff --git a/src/main/resources/migrations/master/003_api_keys.sql b/src/main/resources/migrations/master/003_api_keys.sql new file mode 100644 index 0000000..b58e989 --- /dev/null +++ b/src/main/resources/migrations/master/003_api_keys.sql @@ -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); diff --git a/src/main/resources/migrations/master/004_funcionalidades.sql b/src/main/resources/migrations/master/004_funcionalidades.sql new file mode 100644 index 0000000..48393ae --- /dev/null +++ b/src/main/resources/migrations/master/004_funcionalidades.sql @@ -0,0 +1,6 @@ +CREATE TABLE funcionalidades ( + id BIGSERIAL PRIMARY KEY, + chave VARCHAR(80) NOT NULL UNIQUE, + nome VARCHAR(120) NOT NULL, + descricao TEXT +); diff --git a/src/main/resources/migrations/master/005_empresa_funcionalidades.sql b/src/main/resources/migrations/master/005_empresa_funcionalidades.sql new file mode 100644 index 0000000..de175b2 --- /dev/null +++ b/src/main/resources/migrations/master/005_empresa_funcionalidades.sql @@ -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) +); diff --git a/src/main/resources/migrations/master/006_evolution_instancias.sql b/src/main/resources/migrations/master/006_evolution_instancias.sql new file mode 100644 index 0000000..9d0b7c3 --- /dev/null +++ b/src/main/resources/migrations/master/006_evolution_instancias.sql @@ -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); diff --git a/src/main/resources/migrations/master/007_configuracoes_plataforma.sql b/src/main/resources/migrations/master/007_configuracoes_plataforma.sql new file mode 100644 index 0000000..14f4c20 --- /dev/null +++ b/src/main/resources/migrations/master/007_configuracoes_plataforma.sql @@ -0,0 +1,5 @@ +CREATE TABLE configuracoes_plataforma ( + chave VARCHAR(100) PRIMARY KEY, + valor TEXT, + atualizado_em TIMESTAMP NOT NULL DEFAULT now() +); diff --git a/src/main/resources/migrations/master/008_planos.sql b/src/main/resources/migrations/master/008_planos.sql new file mode 100644 index 0000000..203aceb --- /dev/null +++ b/src/main/resources/migrations/master/008_planos.sql @@ -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() +); diff --git a/src/main/resources/migrations/master/009_plano_funcionalidades.sql b/src/main/resources/migrations/master/009_plano_funcionalidades.sql new file mode 100644 index 0000000..523afad --- /dev/null +++ b/src/main/resources/migrations/master/009_plano_funcionalidades.sql @@ -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) +); diff --git a/src/main/resources/migrations/master/010_assinaturas.sql b/src/main/resources/migrations/master/010_assinaturas.sql new file mode 100644 index 0000000..f937175 --- /dev/null +++ b/src/main/resources/migrations/master/010_assinaturas.sql @@ -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); diff --git a/src/main/resources/migrations/master/011_periodos_uso.sql b/src/main/resources/migrations/master/011_periodos_uso.sql new file mode 100644 index 0000000..2fa1c6a --- /dev/null +++ b/src/main/resources/migrations/master/011_periodos_uso.sql @@ -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); diff --git a/src/main/resources/migrations/master/012_pagamentos.sql b/src/main/resources/migrations/master/012_pagamentos.sql new file mode 100644 index 0000000..f0fd7d7 --- /dev/null +++ b/src/main/resources/migrations/master/012_pagamentos.sql @@ -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); diff --git a/src/main/resources/migrations/master/013_logs_envio_avisos.sql b/src/main/resources/migrations/master/013_logs_envio_avisos.sql new file mode 100644 index 0000000..4a7a35a --- /dev/null +++ b/src/main/resources/migrations/master/013_logs_envio_avisos.sql @@ -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); diff --git a/src/main/resources/migrations/master/014_lembretes_cobranca.sql b/src/main/resources/migrations/master/014_lembretes_cobranca.sql new file mode 100644 index 0000000..a80e2c2 --- /dev/null +++ b/src/main/resources/migrations/master/014_lembretes_cobranca.sql @@ -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() +); diff --git a/src/main/resources/migrations/master/015_seed_funcionalidades.sql b/src/main/resources/migrations/master/015_seed_funcionalidades.sql new file mode 100644 index 0000000..e800342 --- /dev/null +++ b/src/main/resources/migrations/master/015_seed_funcionalidades.sql @@ -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; diff --git a/src/main/resources/migrations/master/016_evolution_numero_unico.sql b/src/main/resources/migrations/master/016_evolution_numero_unico.sql new file mode 100644 index 0000000..b3cf103 --- /dev/null +++ b/src/main/resources/migrations/master/016_evolution_numero_unico.sql @@ -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 $$; diff --git a/src/main/resources/migrations/master/017_funcionarios_telefone.sql b/src/main/resources/migrations/master/017_funcionarios_telefone.sql new file mode 100644 index 0000000..33ff3af --- /dev/null +++ b/src/main/resources/migrations/master/017_funcionarios_telefone.sql @@ -0,0 +1 @@ +ALTER TABLE funcionarios ADD COLUMN IF NOT EXISTS telefone VARCHAR(30); diff --git a/src/main/resources/migrations/master/018_periodos_mes_fechado.sql b/src/main/resources/migrations/master/018_periodos_mes_fechado.sql new file mode 100644 index 0000000..c962b98 --- /dev/null +++ b/src/main/resources/migrations/master/018_periodos_mes_fechado.sql @@ -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; diff --git a/src/main/resources/migrations/tenant/001_condominios.sql b/src/main/resources/migrations/tenant/001_condominios.sql new file mode 100644 index 0000000..c05b448 --- /dev/null +++ b/src/main/resources/migrations/tenant/001_condominios.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/002_contatos.sql b/src/main/resources/migrations/tenant/002_contatos.sql new file mode 100644 index 0000000..38dd347 --- /dev/null +++ b/src/main/resources/migrations/tenant/002_contatos.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/003_contato_condominio.sql b/src/main/resources/migrations/tenant/003_contato_condominio.sql new file mode 100644 index 0000000..d2a8e55 --- /dev/null +++ b/src/main/resources/migrations/tenant/003_contato_condominio.sql @@ -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) +); diff --git a/src/main/resources/migrations/tenant/004_funcionario_condominio.sql b/src/main/resources/migrations/tenant/004_funcionario_condominio.sql new file mode 100644 index 0000000..87b2d0b --- /dev/null +++ b/src/main/resources/migrations/tenant/004_funcionario_condominio.sql @@ -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) +); diff --git a/src/main/resources/migrations/tenant/005_espacos_comuns.sql b/src/main/resources/migrations/tenant/005_espacos_comuns.sql new file mode 100644 index 0000000..a008a0f --- /dev/null +++ b/src/main/resources/migrations/tenant/005_espacos_comuns.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/006_assembleias.sql b/src/main/resources/migrations/tenant/006_assembleias.sql new file mode 100644 index 0000000..5be5c7f --- /dev/null +++ b/src/main/resources/migrations/tenant/006_assembleias.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/007_documentos.sql b/src/main/resources/migrations/tenant/007_documentos.sql new file mode 100644 index 0000000..9c2af11 --- /dev/null +++ b/src/main/resources/migrations/tenant/007_documentos.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/008_avisos.sql b/src/main/resources/migrations/tenant/008_avisos.sql new file mode 100644 index 0000000..e5c26dd --- /dev/null +++ b/src/main/resources/migrations/tenant/008_avisos.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/009_aviso_documentos.sql b/src/main/resources/migrations/tenant/009_aviso_documentos.sql new file mode 100644 index 0000000..253971e --- /dev/null +++ b/src/main/resources/migrations/tenant/009_aviso_documentos.sql @@ -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) +); diff --git a/src/main/resources/migrations/tenant/010_aviso_destinatarios.sql b/src/main/resources/migrations/tenant/010_aviso_destinatarios.sql new file mode 100644 index 0000000..fb16f4f --- /dev/null +++ b/src/main/resources/migrations/tenant/010_aviso_destinatarios.sql @@ -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) +); diff --git a/src/main/resources/migrations/tenant/011_log_auditoria.sql b/src/main/resources/migrations/tenant/011_log_auditoria.sql new file mode 100644 index 0000000..c6fbf80 --- /dev/null +++ b/src/main/resources/migrations/tenant/011_log_auditoria.sql @@ -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() +); diff --git a/src/main/resources/migrations/tenant/012_indices.sql b/src/main/resources/migrations/tenant/012_indices.sql new file mode 100644 index 0000000..9067704 --- /dev/null +++ b/src/main/resources/migrations/tenant/012_indices.sql @@ -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); diff --git a/src/main/resources/migrations/tenant/013_condominios_cnpj_obrigatorio.sql b/src/main/resources/migrations/tenant/013_condominios_cnpj_obrigatorio.sql new file mode 100644 index 0000000..2acad77 --- /dev/null +++ b/src/main/resources/migrations/tenant/013_condominios_cnpj_obrigatorio.sql @@ -0,0 +1,2 @@ +ALTER TABLE condominios + ADD CONSTRAINT condominios_cnpj_obrigatorio CHECK (cnpj IS NOT NULL AND btrim(cnpj) <> '') NOT VALID; diff --git a/src/main/resources/migrations/tenant/014_lembretes.sql b/src/main/resources/migrations/tenant/014_lembretes.sql new file mode 100644 index 0000000..764d67d --- /dev/null +++ b/src/main/resources/migrations/tenant/014_lembretes.sql @@ -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); diff --git a/src/main/resources/migrations/tenant/015_fila_envio.sql b/src/main/resources/migrations/tenant/015_fila_envio.sql new file mode 100644 index 0000000..70ed7a7 --- /dev/null +++ b/src/main/resources/migrations/tenant/015_fila_envio.sql @@ -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(); diff --git a/src/main/resources/migrations/tenant/016_recorrencia_lembretes_avisos.sql b/src/main/resources/migrations/tenant/016_recorrencia_lembretes_avisos.sql new file mode 100644 index 0000000..fc21820 --- /dev/null +++ b/src/main/resources/migrations/tenant/016_recorrencia_lembretes_avisos.sql @@ -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); diff --git a/src/main/resources/migrations/tenant/017_destinatarios_extra.sql b/src/main/resources/migrations/tenant/017_destinatarios_extra.sql new file mode 100644 index 0000000..c40d6a8 --- /dev/null +++ b/src/main/resources/migrations/tenant/017_destinatarios_extra.sql @@ -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); diff --git a/src/main/webapp/cliente/condominio-detalhe.html b/src/main/webapp/cliente/condominio-detalhe.html new file mode 100644 index 0000000..be15638 --- /dev/null +++ b/src/main/webapp/cliente/condominio-detalhe.html @@ -0,0 +1 @@ +Detalhe do Condominio

Detalhe do Condominio

\ No newline at end of file diff --git a/src/main/webapp/cliente/condominio-detalhe.js b/src/main/webapp/cliente/condominio-detalhe.js new file mode 100644 index 0000000..efa8556 --- /dev/null +++ b/src/main/webapp/cliente/condominio-detalhe.js @@ -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','');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=>'
'+x+'
').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+=''}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='

Nenhuma assembleia agendada neste dia.

';return;}list.innerHTML=items.map(a=>{const dest=(a.destinatarios||[]).map(x=>x.nome+' ('+x.tipo+')').join(', ')||'Nenhum destinatário registrado';return '
'+a.condominio_nome+''+Util.date(a.data_hora)+''+a.tipo+' - '+(a.local||'-')+'

'+dest+'

'}).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','');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=>'').join('')||'

Nenhuma pessoa cadastrada.

';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=`

${e.message}

`;Util.toast(e.message,true)}} +async function load(){try{const d=await Api.get(`/api/condominios/${id}`);document.querySelector('#header').innerHTML=`

${d.condominio.nome}

${d.condominio.endereco||''}

Sindico${d.sindico_atual?.contato_nome||'-'}
Assembleias futuras${d.assembleias_futuras||0}
Avisos ativos${d.avisos_ativos||0}
`;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=`

Contatos

${contatos.map(c=>``).join('')}
NomeTipoTelefoneEmail
${c.contato_nome}${c.tipo}${c.contato_telefone||''}${c.contato_email||''}
`;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=`

Espacos comuns

${(d.espacos||[]).map(x=>``).join('')}
NomeValorIndiceDescricao
${x.nome}${Util.money(x.valor_utilizacao_centavos)}${x.indice_correcao||''}${x.descricao||''}
`;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=`

Assembleias

${(d.assembleias||[]).map(a=>``).join('')}
TipoDataLocalStatus
${a.tipo}${Util.date(a.data_hora)}${a.local||''}${Util.badge(a.status)}
`;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=`

Documentos

${docs.map(d=>``).join('')}
IDArquivoTipoDownload
${d.id}${d.nome_arquivo}${d.tipo||''}Baixar
`;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=>``).join('');const destFuncionarios=funcionariosVinculados.map(f=>``).join('');const destExtras=extras.map(x=>``).join('');const dest=[destContatos,destFuncionarios,destExtras].filter(Boolean).join('');const docChecks=docs.map(d=>``).join('');document.querySelector('#avisos').innerHTML=`

Avisos

Nenhuma pessoa selecionada
${docChecks||'

Nenhum documento.

'}
${avisos.map(a=>``).join('')}
TituloStatusEnvioPublicado emRecorrenciaAcoes
${a.titulo}${Util.badge(a.status_envio)}${Util.date(a.data_evento)}${Util.date(a.data_publicacao)}${recLabel(a.recorrencia_meses)}
`;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(); \ No newline at end of file diff --git a/src/main/webapp/cliente/condominios.html b/src/main/webapp/cliente/condominios.html new file mode 100644 index 0000000..32e7cc4 --- /dev/null +++ b/src/main/webapp/cliente/condominios.html @@ -0,0 +1 @@ +Cadastro

Cadastro

Novo condominio

Condominios

IDNomeCNPJEndereco
\ No newline at end of file diff --git a/src/main/webapp/cliente/condominios.js b/src/main/webapp/cliente/condominios.js new file mode 100644 index 0000000..a825061 --- /dev/null +++ b/src/main/webapp/cliente/condominios.js @@ -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=>`${c.id}${c.nome}${c.cnpj||''}${c.endereco||''}Abrir`).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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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'?'Acesso total':'';const opcoes=f.cargo==='admin_empresa'?'-':' ';return ''+esc(f.nome)+''+esc(f.email)+''+esc(f.telefone||'')+''+esc(f.cargo||'')+''+Util.badge(f.status)+''+acao+''+opcoes+''}).join('')||'Nenhum funcionario cadastrado.'}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='
';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='

Deseja realmente excluir o acesso de '+esc(f.nome||'')+'?

O funcionario sera inativado e perdera acesso ao sistema.

';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=>`
${esc(c.nome)}
`).join('')||'

Nenhum condominio vinculado.

';const listaDisponiveis=disponiveis.map(c=>`
${esc(c.nome)}
`).join('')||'

Todos os condominios ja estao vinculados.

';modalBody.innerHTML=``} +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=>''+esc(x.nome)+''+esc(x.email||'')+''+esc(x.telefone||'')+''+Util.badge(x.status)+' ').join('')||'Nenhum destinatario extra cadastrado.'}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='
';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(); diff --git a/src/main/webapp/cliente/configuracoes.html b/src/main/webapp/cliente/configuracoes.html new file mode 100644 index 0000000..d5afdbb --- /dev/null +++ b/src/main/webapp/cliente/configuracoes.html @@ -0,0 +1 @@ +WhatsApp

WhatsApp

WhatsApp

Status

\ No newline at end of file diff --git a/src/main/webapp/cliente/configuracoes.js b/src/main/webapp/cliente/configuracoes.js new file mode 100644 index 0000000..27e1570 --- /dev/null +++ b/src/main/webapp/cliente/configuracoes.js @@ -0,0 +1 @@ +const form=document.querySelector('#wa-form'),statusEl=document.querySelector('#status'),qr=document.querySelector('#qr'),qrBox=document.querySelector('#qr-box'),pairing=document.querySelector('#pairing'),btnQr=document.querySelector('#btn-qr'),btnReconnect=document.querySelector('#btn-reconnect'),btnDisconnect=document.querySelector('#btn-disconnect');async function load(){try{const d=await Api.get('/api/config/evolution-instancia/status');const i=d.instancia;const evo=d.evolution||{};statusEl.innerHTML=i?`

Status aprovacao: ${Util.badge(i.status)}

Numero: ${i.numero_whatsapp||'-'}

Conexao: ${Util.badge(evo.state||'-')}

`:`

Status: ${Util.badge(d.status)}

`;const has=i!=null;btnQr.disabled=!has;btnReconnect.disabled=!has;btnDisconnect.disabled=!has}catch(e){Util.toast(e.message,true)}}function showQr(d){qrBox.classList.remove('hidden');const base64=d.qrcode_base64||d.qr_code_base64;if(base64){qr.src=base64.startsWith('data:image/')?base64:'data:image/png;base64,'+base64;qr.classList.remove('hidden')}else{qr.classList.add('hidden');Util.toast('Evolution nao retornou QR Code nesta tentativa',true)}pairing.value=d.pairing_code||''}form.onsubmit=async e=>{e.preventDefault();try{const d=await Api.post('/api/config/evolution-instancia/conectar',Util.formData(form));statusEl.innerHTML=`

WhatsApp cadastrado com sucesso. Aguarde a liberacao no painel Master.

Status aprovacao: ${Util.badge(d.status)}

Instancia: ${d.instance_name}

`;showQr(d);Util.toast('WhatsApp cadastrado com sucesso');setTimeout(load,1600)}catch(err){Util.toast(err.message,true)}};btnQr.onclick=async()=>{try{const d=await Api.post('/api/config/evolution-instancia/qrcode',{});showQr(d);Util.toast('QR Code atualizado')}catch(e){Util.toast(e.message,true)}};btnReconnect.onclick=async()=>{try{await Api.post('/api/config/evolution-instancia/reconectar',{});Util.toast('Reconexao solicitada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};btnDisconnect.onclick=async()=>{if(!confirm('Desconectar esta sessao do WhatsApp?'))return;try{await Api.post('/api/config/evolution-instancia/desconectar',{});Util.toast('Sessao desconectada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};load(); \ No newline at end of file diff --git a/src/main/webapp/cliente/financeiro.html b/src/main/webapp/cliente/financeiro.html new file mode 100644 index 0000000..8abc365 --- /dev/null +++ b/src/main/webapp/cliente/financeiro.html @@ -0,0 +1 @@ +Financeiro

Financeiro

Periodo atual

\ No newline at end of file diff --git a/src/main/webapp/cliente/financeiro.js b/src/main/webapp/cliente/financeiro.js new file mode 100644 index 0000000..b5bef8e --- /dev/null +++ b/src/main/webapp/cliente/financeiro.js @@ -0,0 +1,7 @@ +let pagamento=null,timer=null;const statusEl=document.querySelector('#status'),pix=document.querySelector('#pix'),qr=document.querySelector('#qr'),payload=document.querySelector('#payload'); +function data(v){if(!v)return'-';const d=new Date(v+'T00:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('pt-BR')} +async function load(){try{const d=await Api.get('/api/financeiro/periodo-atual');pagamento=d.pagamento;const p=d.periodo||{},a=d.assinatura||{};statusEl.innerHTML='
Periodo de utilizacao'+data(p.data_inicio)+' a '+data(p.data_fim)+'Mes fechado
Vencimento'+data(p.data_vencimento)+'Dia '+(a.dia_vencimento||7)+' de cada mes
Mensalidade'+Util.money(a.valor_mensal_centavos)+''+Util.badge(p.status)+'
Pagamento'+Util.badge(pagamento?.status)+'
';if(pagamento&&pagamento.qr_code_base64)showPix(pagamento)}catch(e){Util.toast(e.message,true)}} +function showPix(p){pix.classList.remove('hidden');qr.src='data:image/png;base64,'+p.qr_code_base64;payload.value=p.qr_code_payload||'';if(timer)clearInterval(timer);timer=setInterval(async()=>{const d=await Api.get('/api/financeiro/pagamentos/'+p.id);if(d.pagamento?.status==='pago'){clearInterval(timer);Util.toast('Pagamento confirmado');load()}},5000)} +document.querySelector('#gerar').onclick=async()=>{try{const d=await Api.post('/api/financeiro/pagamentos',{});pagamento=d.pagamento;showPix(pagamento);load()}catch(e){Util.toast(e.message,true)}}; +document.querySelector('#copy').onclick=()=>navigator.clipboard.writeText(payload.value).then(()=>Util.toast('Codigo copiado')); +load(); diff --git a/src/main/webapp/cliente/lembretes.html b/src/main/webapp/cliente/lembretes.html new file mode 100644 index 0000000..03e13ee --- /dev/null +++ b/src/main/webapp/cliente/lembretes.html @@ -0,0 +1 @@ +Lembrete

Lembrete

Novo lembrete

Lembretes

TituloData/horaStatusDestinatariosAcoes
\ No newline at end of file diff --git a/src/main/webapp/cliente/lembretes.js b/src/main/webapp/cliente/lembretes.js new file mode 100644 index 0000000..7e9029a --- /dev/null +++ b/src/main/webapp/cliente/lembretes.js @@ -0,0 +1,20 @@ +const form=document.querySelector('#lembrete-form'),rows=document.querySelector('#rows'),pessoasEl=document.querySelector('#pessoas'),submitBtn=document.querySelector('#submit-btn'),cancelBtn=document.querySelector('#cancel-edit'); +let pessoas=[],lembretes=[]; +function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} +function toInputDate(v){if(!v)return'';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())}T${pad(d.getHours())}:${pad(d.getMinutes())}`} +function recLabel(v){return {1:"Mensal",3:"Trimestral",12:"Anual"}[Number(v)]||"Sem recorrencia"} +function selectedDestIds(){return new Set([...form.querySelectorAll('input[name=dest]:checked')].map(x=>String(x.value)))} +function updateNotifSummary(){const n=selectedDestIds().size;const el=document.querySelector('#notif-summary');if(el)el.textContent=n?(n+' selecionado'+(n>1?'s':'')):'Nenhuma pessoa selecionada'} +function ensureNotifModal(){let m=document.querySelector('#notif-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','');return document.querySelector('#notif-modal')} +window.abrirNotificacao=()=>{const m=ensureNotifModal(),ids=selectedDestIds();document.querySelector('#notif-modal-body').innerHTML=pessoas.map(p=>'').join('')||'

Nenhum funcionario ativo.

';m.classList.remove('hidden')}; +window.fecharNotificacao=()=>document.querySelector('#notif-modal')?.classList.add('hidden'); +window.salvarNotificacao=()=>{const ids=new Set([...document.querySelectorAll('#notif-modal-body input:checked')].map(x=>String(x.value)));form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=ids.has(String(x.value)));updateNotifSummary();fecharNotificacao()}; +async function loadPessoas(){const d=await Api.get('/api/lembrete-pessoas');pessoas=d.pessoas||[];pessoasEl.innerHTML=' Nenhuma pessoa selecionada';updateNotifSummary()} +async function load(){try{const d=await Api.get('/api/lembretes');lembretes=d.lembretes||[];rows.innerHTML=lembretes.map(l=>{const dest=(l.destinatarios||[]).map(x=>x.nome).join(', ')||'-';const acoes=l.pode_alterar?` ${l.pode_editar?``:''} `:'-';return `${esc(l.titulo)}
${esc(l.mensagem)}${Util.date(l.data_hora)}
${recLabel(l.recorrencia_meses)}${Util.badge(l.status)}${esc(dest)}${acoes}`}).join('')||'Nenhum lembrete cadastrado.'}catch(e){Util.toast(e.message,true)}} +function resetForm(){form.reset();form.id.value='';form.recorrencia_meses.value='0';submitBtn.textContent='Cadastrar lembrete';cancelBtn.classList.add('hidden');form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=false);updateNotifSummary()} +window.editar=id=>{const l=lembretes.find(x=>Number(x.id)===Number(id));if(!l)return;if(!l.pode_editar){Util.toast('Este lembrete ja foi enviado e nao pode mais ser editado.',true);return;}form.id.value=l.id;form.titulo.value=l.titulo||'';form.mensagem.value=l.mensagem||'';form.data_hora.value=toInputDate(l.data_hora);form.status.value='ativo';form.recorrencia_meses.value=String(l.recorrencia_meses||0);const ids=new Set((l.destinatarios||[]).map(x=>Number(x.id)));form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=ids.has(Number(x.value)));updateNotifSummary();submitBtn.textContent='Salvar alteracao';cancelBtn.classList.remove('hidden');scrollTo({top:0,behavior:'smooth'})}; +window.enviar=async id=>{try{const r=await Api.post(`/api/lembretes/${id}/enviar`,{});const en=r.envio||{};Util.toast(en.falhas?("Enviados: "+(en.enviados||0)+". Falhas: "+en.falhas):("Lembrete enviado para "+(en.enviados||0)));await load()}catch(e){Util.toast(e.message,true)}}; +window.excluir=async id=>{try{await Api.request(`/api/lembretes/${id}`,{method:'DELETE'});Util.toast('Lembrete excluido');await load();resetForm()}catch(e){Util.toast(e.message,true)}}; +cancelBtn.onclick=resetForm; +form.onsubmit=async e=>{e.preventDefault();const b=Util.formData(form);b.destinatarios=[...form.querySelectorAll('input[name=dest]:checked')].map(x=>Number(x.value));try{if(form.id.value){await Api.put(`/api/lembretes/${form.id.value}`,b);Util.toast('Lembrete atualizado')}else{const r=await Api.post('/api/lembretes',b);const en=r.envio||{};Util.toast(en.enfileirado?'Lembrete enfileirado para envio':(en.falhas?('Lembrete salvo. Enviados: '+(en.enviados||0)+'. Falhas: '+en.falhas):'Lembrete cadastrado'))}resetForm();await load()}catch(err){Util.toast(err.message,true)}}; +(async()=>{try{await loadPessoas();await load();setInterval(load,5000)}catch(e){Util.toast(e.message,true)}})(); diff --git a/src/main/webapp/css/app.css b/src/main/webapp/css/app.css new file mode 100644 index 0000000..be15150 --- /dev/null +++ b/src/main/webapp/css/app.css @@ -0,0 +1,4 @@ +:root{--bg:#f6f7f9;--panel:#fff;--line:#d9dee7;--text:#1c2430;--muted:#667085;--brand:#176b87;--brand-strong:#0f5268;--ok:#1f8a5b;--warn:#b7791f;--danger:#b42318}*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)}a{color:inherit}button,input,select,textarea{font:inherit}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px}.login-box,.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 16px 40px rgba(16,24,40,.08)}.login-box{width:min(440px,100%);padding:28px}.tabs,.nav-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);margin-bottom:20px}.tabs button,.nav-tabs button{border:0;background:transparent;padding:10px 12px;cursor:pointer;color:var(--muted)}.tabs button.active,.nav-tabs button.active{color:var(--brand);border-bottom:3px solid var(--brand)}.field{display:grid;gap:6px;margin-bottom:12px}label{color:var(--muted);font-size:13px}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:6px;padding:10px 12px;background:#fff}textarea{min-height:88px;resize:vertical}.btn{border:0;border-radius:6px;padding:10px 14px;color:#fff;background:var(--brand);cursor:pointer;display:inline-flex;align-items:center;gap:8px;justify-content:center}.btn:hover{background:var(--brand-strong)}.btn.secondary{color:var(--text);background:#eef2f6}.btn.danger{background:var(--danger)}.app{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:#102a38;color:#fff;padding:22px 16px;display:flex;flex-direction:column;gap:20px}.brand{font-size:20px;font-weight:700}.user{color:#c9d6df;font-size:13px}.menu{display:grid;gap:6px}.menu a{text-decoration:none;padding:10px 12px;border-radius:6px;color:#dbe6ec}.menu a.active,.menu a:hover{background:rgba(255,255,255,.12);color:#fff}.sidebar .btn{margin-top:auto;width:100%;background:rgba(255,255,255,.14)}.content{padding:28px;min-width:0}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}h1{margin:0;font-size:26px}h2{margin:0 0 14px;font-size:18px}.grid{display:grid;gap:16px}.cols{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.panel{padding:18px;box-shadow:none}.panel-block{margin-top:16px}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:680px}th,td{padding:10px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}th{color:var(--muted);font-size:12px;text-transform:uppercase}.badge{display:inline-block;padding:4px 8px;border-radius:999px;background:#eef2f6;font-size:12px}.badge.ok{background:#dcfce7;color:var(--ok)}.badge.warn{background:#fef3c7;color:var(--warn)}.toast,.finance-banner{position:fixed;left:50%;transform:translateX(-50%);z-index:20;max-width:min(720px,calc(100vw - 24px));border-radius:8px;padding:12px 14px}.toast{bottom:18px;background:#17202a;color:#fff}.toast.error{background:var(--danger)}.finance-banner{top:12px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412}.hidden{display:none!important}.qr{max-width:260px;border:1px solid var(--line);border-radius:8px;padding:8px;background:#fff}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.stat{border:1px solid var(--line);border-radius:8px;padding:14px;background:#fff}.stat strong{display:block;font-size:22px;margin-top:6px}@media(max-width:820px){.app{grid-template-columns:1fr}.sidebar{position:static}.content{padding:18px}}.status-action{border:0;background:transparent;padding:0;display:inline-flex;align-items:center;gap:8px;cursor:pointer}.status-action span{font-size:12px;color:var(--brand);text-decoration:underline}.status-action:hover span{color:var(--brand-strong)}.inline-panel{background:#f8fafc;border:1px solid var(--line);border-radius:8px;padding:14px}.inline-panel h2{margin-bottom:10px}.inline-panel p{margin:6px 0}.row.compact{align-items:center;gap:8px}.row.compact select{min-width:180px}.link-button{border:0;background:transparent;color:var(--brand);cursor:pointer;font-size:12px;text-decoration:underline;padding:0 0 0 6px}.link-button:hover{color:var(--brand-strong)} +.modal{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:24px}.modal-backdrop{position:absolute;inset:0;background:rgba(15,23,42,.45)}.modal-panel{position:relative;width:min(860px,100%);max-height:calc(100vh - 48px);overflow:auto;background:#fff;border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 24px 80px rgba(15,23,42,.24)}.modal-head{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:16px}.modal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.modal-grid h3{margin:0 0 10px;font-size:15px}.cond-item{display:flex;justify-content:space-between;gap:12px;align-items:center;border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:8px}.muted{color:var(--muted)}@media(max-width:720px){.modal-grid{grid-template-columns:1fr}.modal{padding:12px}} +.success-message{display:inline-block;margin:0 0 10px;padding:10px 12px;border:1px solid #bbf7d0;border-radius:8px;background:#dcfce7;color:var(--ok);font-weight:700} +.section-head{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:14px}.section-head h2{margin:0}.icon-btn{position:relative;width:42px;height:42px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer;display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--brand);background:#eef8fb}.calendar-open::before{content:"";width:20px;height:18px;border:2px solid var(--brand);border-radius:3px;box-shadow:inset 0 5px 0 #e8f3f7;position:relative}.calendar-open::after{content:"";position:absolute;width:12px;height:2px;background:var(--brand);transform:translateY(-7px)}.calendar-modal{width:min(1120px,100%)}.calendar-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px}.calendar-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.calendar-toolbar strong{text-transform:capitalize}.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff}.calendar-weekday{padding:10px;background:#f8fafc;color:var(--muted);font-size:12px;font-weight:700;text-align:center;border-bottom:1px solid var(--line)}.calendar-day{min-height:86px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff;padding:8px;text-align:left;color:var(--text);cursor:pointer;position:relative}.calendar-day:nth-child(7n+7){border-right:0}.calendar-day.other{background:#f8fafc;color:#98a2b3}.calendar-day.busy{background:#fff7ed}.calendar-day.busy span{font-weight:700;color:var(--warn)}.calendar-day.today{box-shadow:inset 0 0 0 2px var(--brand)}.calendar-day.selected{background:#e8f3f7}.calendar-day b{position:absolute;right:8px;bottom:8px;background:var(--brand);color:#fff;border-radius:999px;padding:2px 7px;font-size:12px}.calendar-side{border:1px solid var(--line);border-radius:8px;padding:14px;background:#fff;min-height:420px}.calendar-side h3{margin:0 0 12px}.calendar-event{border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;display:grid;gap:6px}.calendar-event span{font-size:13px}.calendar-event p{margin:0;color:var(--muted);font-size:13px}@media(max-width:900px){.calendar-layout{grid-template-columns:1fr}.calendar-day{min-height:64px}.calendar-side{min-height:auto}} diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html new file mode 100644 index 0000000..7fe4292 --- /dev/null +++ b/src/main/webapp/index.html @@ -0,0 +1 @@ +SeConectai \ No newline at end of file diff --git a/src/main/webapp/js/api.js b/src/main/webapp/js/api.js new file mode 100644 index 0000000..955b0b9 --- /dev/null +++ b/src/main/webapp/js/api.js @@ -0,0 +1 @@ +window.Api={async request(path,options={}){const headers=options.headers||{};const token=Auth.token();if(token)headers.Authorization=`Bearer ${token}`;if(options.body&&!(options.body instanceof FormData))headers['Content-Type']='application/json';const res=await fetch(path,{...options,headers});const text=await res.text();const data=text?JSON.parse(text):{};if(res.status===401){Auth.logout();return}if(res.status===403&&data.erro==='pendencia_financeira'){this.financeBanner(data.mensagem);throw new Error(data.mensagem||'Pendencia financeira')}if(!res.ok)throw new Error(data.mensagem||data.erro||`Erro HTTP ${res.status}`);return data},get(path){return this.request(path)},post(path,body){return this.request(path,{method:'POST',body:body instanceof FormData?body:JSON.stringify(body||{})})},put(path,body){return this.request(path,{method:'PUT',body:JSON.stringify(body||{})})},financeBanner(message){if(document.querySelector('.finance-banner'))return;const banner=document.createElement('div');banner.className='finance-banner';banner.innerHTML=`${message||'Seu acesso esta bloqueado por pendencia de pagamento.'} Regularizar em Financeiro`;document.body.appendChild(banner)}}; \ No newline at end of file diff --git a/src/main/webapp/js/auth.js b/src/main/webapp/js/auth.js new file mode 100644 index 0000000..15d90ea --- /dev/null +++ b/src/main/webapp/js/auth.js @@ -0,0 +1 @@ +window.Auth={claims(){const t=this.token();if(!t)return{};try{return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')))}catch(_){return{}}},isAdminEmpresa(){return this.claims().cargo==='admin_empresa'},session(){const scope=localStorage.getItem('auth_scope');const token=scope==='master'?localStorage.getItem('master_token'):localStorage.getItem('funcionario_token');return token?{scope,token}:null},token(){const s=this.session();return s?s.token:null},save(scope,token,label){localStorage.setItem('auth_scope',scope);localStorage.setItem(scope==='master'?'master_token':'funcionario_token',token);localStorage.setItem('auth_label',label||(scope==='master'?'Master':'Escritorio'))},logout(){localStorage.removeItem('auth_scope');localStorage.removeItem('master_token');localStorage.removeItem('funcionario_token');localStorage.removeItem('auth_label');location.href='/login.html'},require(scope){const s=this.session();if(!s||(scope&&s.scope!==scope))location.href='/login.html';return s}}; \ No newline at end of file diff --git a/src/main/webapp/js/layout.js b/src/main/webapp/js/layout.js new file mode 100644 index 0000000..671e091 --- /dev/null +++ b/src/main/webapp/js/layout.js @@ -0,0 +1 @@ +window.Layout={mount(scope,active){Auth.require(scope);const label=localStorage.getItem('auth_label')||(scope==='master'?'Master':'Escritorio');const master=[['Clientes','/master/clientes.html','clientes'],['Planos','/master/planos.html','planos'],['Financeiro','/master/financeiro.html','financeiro'],['Configuracoes','/master/configuracoes.html','configuracoes']];let cliente=[['Cadastro','/cliente/condominios.html','condominios'],['Lembrete','/cliente/lembretes.html','lembretes'],['Financeiro','/cliente/financeiro.html','financeiro'],['WhatsApp','/cliente/configuracoes.html','configuracoes']];if(scope==='empresa'&&!Auth.isAdminEmpresa())cliente=cliente.filter(x=>x[2]!=='configuracoes');const menu=(scope==='master'?master:cliente).map(([n,h,k])=>`${n}`).join('');document.body.insertAdjacentHTML('afterbegin',``);document.body.classList.add('app');document.querySelector('main')?.classList.add('content')}}; \ No newline at end of file diff --git a/src/main/webapp/js/util.js b/src/main/webapp/js/util.js new file mode 100644 index 0000000..e8a04bb --- /dev/null +++ b/src/main/webapp/js/util.js @@ -0,0 +1 @@ +window.Util={money(cents){return(Number(cents||0)/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'})},date(value){return value?new Date(value).toLocaleString('pt-BR'):'-'},toast(message,error){const el=document.createElement('div');el.className='toast'+(error?' error':'');el.textContent=message;document.body.appendChild(el);setTimeout(()=>el.remove(),3600)},formData(form){return Object.fromEntries(new FormData(form).entries())},badge(status){const s=status||'-';const klass=['ativa','ativo','pago','enviado','aprovada'].includes(s)?'ok':(['pendente','bloqueada','falhou'].includes(s)?'warn':'');return `${s}`}}; \ No newline at end of file diff --git a/src/main/webapp/login.html b/src/main/webapp/login.html new file mode 100644 index 0000000..9e88a96 --- /dev/null +++ b/src/main/webapp/login.html @@ -0,0 +1 @@ +Login - SeConectai

SeConectai

Acesse o painel administrativo ou o escritorio.

\ No newline at end of file diff --git a/src/main/webapp/master/clientes.html b/src/main/webapp/master/clientes.html new file mode 100644 index 0000000..7288bf4 --- /dev/null +++ b/src/main/webapp/master/clientes.html @@ -0,0 +1 @@ +Clientes

Clientes

SlugNomeStatusPlanoEditar
\ No newline at end of file diff --git a/src/main/webapp/master/clientes.js b/src/main/webapp/master/clientes.js new file mode 100644 index 0000000..d150f8d --- /dev/null +++ b/src/main/webapp/master/clientes.js @@ -0,0 +1,18 @@ +const empresaForm=document.querySelector('#empresa-form'),rows=document.querySelector('#rows'),busca=document.querySelector('#busca'),planoSelect=document.querySelector('#plano-select');let empresas=[],planos=[],modo=null,selecionado=null; +function planoLabel(p){return `${p.nome} - ${Util.money(p.valor_mensal_centavos)}`} +function planoOptions(valor){return ''+planos.map(p=>``).join('')} +function renderPlanos(){planoSelect.innerHTML=planoOptions('')} +function match(e,q){if(!q)return true;return [e.slug,e.nome,e.cnpj,e.status,e.plano].join(' ').toLowerCase().includes(q.toLowerCase())} +function statusAction(e){const acao=e.status==='ativa'?'Bloquear':'Ativar';return ``} +function detalheRow(e){if(selecionado!==e.id||!modo)return'';if(modo==='ver')return `

${e.nome}

Slug: ${e.slug}

CNPJ: ${e.cnpj||'-'}

Status: ${e.status}

Plano: ${e.plano?`${e.plano} ${e.plano_valor_centavos?'- '+Util.money(e.plano_valor_centavos):''}`:'-'}

Conta principal: abra Editar para alterar dados da empresa; funcionarios ficam no acesso da empresa.

`;return `
`} +function render(){const q=busca.value.trim();const lista=empresas.filter(e=>match(e,q));rows.innerHTML=lista.length?lista.map(e=>`${e.slug}${e.nome}${statusAction(e)}${e.plano?`${e.plano} ${e.plano_valor_centavos?'- '+Util.money(e.plano_valor_centavos):''}`:'-'} ${detalheRow(e)}`).join(''):'Nenhum cliente encontrado.'} +async function load(){try{const [pe,de]=await Promise.all([Api.get('/api/master/planos'),Api.get('/api/master/empresas')]);planos=pe.planos||[];empresas=de.empresas||[];renderPlanos();render()}catch(e){Util.toast(e.message,true)}} +empresaForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/master/empresas',Util.formData(empresaForm));empresaForm.reset();Util.toast('Cliente cadastrado');load()}catch(err){Util.toast(err.message,true)}}; +busca.oninput=render; +window.ver=id=>{selecionado=selecionado===id&&modo==='ver'?null:id;modo=selecionado?'ver':null;render()}; +window.editar=id=>{selecionado=selecionado===id&&modo==='editar'?null:id;modo=selecionado?'editar':null;render()}; +window.fecharDetalhe=()=>{selecionado=null;modo=null;render()}; +window.salvarEdicao=async(ev,id)=>{ev.preventDefault();try{await Api.put(`/api/master/empresas/${id}`,Util.formData(ev.target));Util.toast('Cliente atualizado');selecionado=null;modo=null;load()}catch(err){Util.toast(err.message,true)}}; +window.alterarStatus=async(id,status)=>{try{await Api.post(`/api/master/empresas/${id}/status`,{status});Util.toast(status==='ativa'?'Cliente ativado':'Cliente bloqueado');load()}catch(err){Util.toast(err.message,true)}}; +window.excluir=async id=>{try{await Api.request(`/api/master/empresas/${id}`,{method:'DELETE'});Util.toast('Cliente excluido');selecionado=null;modo=null;load()}catch(err){Util.toast(err.message,true)}}; +load(); \ No newline at end of file diff --git a/src/main/webapp/master/configuracoes.html b/src/main/webapp/master/configuracoes.html new file mode 100644 index 0000000..d19e71f --- /dev/null +++ b/src/main/webapp/master/configuracoes.html @@ -0,0 +1 @@ +Configuracoes

Configuracoes

E-mail

AbacatePay

Acessos WhatsApp

EmpresaNumeroStatusSolicitado emAcoes
\ No newline at end of file diff --git a/src/main/webapp/master/configuracoes.js b/src/main/webapp/master/configuracoes.js new file mode 100644 index 0000000..ef60ce4 --- /dev/null +++ b/src/main/webapp/master/configuracoes.js @@ -0,0 +1 @@ +const emailForm=document.querySelector('#email-form'),abacateForm=document.querySelector('#abacate-form'),emailRows=document.querySelector('#email-rows'),abacateRows=document.querySelector('#abacate-rows'),evolutionRows=document.querySelector('#evolution-rows');let empresas=[];const emailKeys=['smtp_from','smtp_host','smtp_password','smtp_port','smtp_starttls','smtp_user'];const abacateKeys=['abacatepay_api_key','abacatepay_webhook_secret'];function renderConfig(target,cfg,keys,labels){target.innerHTML=keys.map(k=>`

${labels[k]||k}: ${cfg[k]||''}

`).join('')}async function load(){try{const d=await Api.get('/api/master/configuracoes');const cfg=d.configuracoes||{};for(const [k,v] of Object.entries(cfg)){if(emailForm.elements[k])emailForm.elements[k].value=v;if(abacateForm.elements[k])abacateForm.elements[k].value=v}renderConfig(emailRows,cfg,emailKeys,{smtp_from:'Remetente',smtp_host:'Host',smtp_password:'Senha',smtp_port:'Porta',smtp_starttls:'StartTLS',smtp_user:'Usuario'});renderConfig(abacateRows,cfg,abacateKeys,{abacatepay_api_key:'API Key',abacatepay_webhook_secret:'Webhook Secret'});await loadEvolution()}catch(e){Util.toast(e.message,true)}}async function loadEvolution(){const d=await Api.get('/api/master/empresas');empresas=d.empresas||[];const grupos=await Promise.all(empresas.map(async e=>{try{const r=await Api.get(`/api/master/empresas/${e.id}/evolution-instancias`);return (r.instancias||[]).map(i=>({...i,empresa_nome:e.nome}))}catch(_){return[]}}));const instancias=grupos.flat();evolutionRows.innerHTML=instancias.length?instancias.map(i=>`${i.empresa_nome}${i.numero_whatsapp||'-'}${Util.badge(i.status)}${Util.date(i.solicitado_em)} `).join(''):'Nenhuma solicitacao encontrada.'}async function aprovar(emp,id){try{await Api.post(`/api/master/empresas/${emp}/evolution-instancias/${id}/aprovar`,{});Util.toast('Acesso liberado');loadEvolution()}catch(e){Util.toast(e.message,true)}}async function rejeitar(emp,id){try{await Api.post(`/api/master/empresas/${emp}/evolution-instancias/${id}/rejeitar`,{});Util.toast('Acesso rejeitado');loadEvolution()}catch(e){Util.toast(e.message,true)}}async function excluir(emp,id){if(!confirm('Excluir esta conexao de WhatsApp?'))return;try{await Api.request(`/api/master/empresas/${emp}/evolution-instancias/${id}`,{method:'DELETE'});Util.toast('Conexao excluida');loadEvolution()}catch(e){Util.toast(e.message,true)}}emailForm.onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/master/configuracoes',Util.formData(emailForm));Util.toast('Configuracoes de e-mail salvas');load()}catch(err){Util.toast(err.message,true)}};abacateForm.onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/master/configuracoes',Util.formData(abacateForm));Util.toast('Configuracoes AbacatePay salvas');load()}catch(err){Util.toast(err.message,true)}};load(); \ No newline at end of file diff --git a/src/main/webapp/master/financeiro.html b/src/main/webapp/master/financeiro.html new file mode 100644 index 0000000..a1ba221 --- /dev/null +++ b/src/main/webapp/master/financeiro.html @@ -0,0 +1 @@ +Financeiro Master

Financeiro Master

Situacao das empresas

EmpresaStatusPeriodo de utilizacaoVencimentoAcao
\ No newline at end of file diff --git a/src/main/webapp/master/financeiro.js b/src/main/webapp/master/financeiro.js new file mode 100644 index 0000000..27aa5e3 --- /dev/null +++ b/src/main/webapp/master/financeiro.js @@ -0,0 +1,8 @@ +const rows=document.querySelector('#rows'),cards=document.querySelector('#cards'); +function data(v){if(!v)return'-';const d=new Date(v+'T00:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('pt-BR')} +function periodo(e){return ''+data(e.data_inicio)+' a '+data(e.data_fim)+'
Vencimento: '+data(e.data_vencimento)+'
'+Util.badge(e.periodo_status||'pendente')} +async function load(){try{const d=await Api.get('/api/master/empresas');const es=d.empresas||[];cards.innerHTML='
Ativas'+es.filter(e=>e.status==='ativa').length+'
Bloqueadas'+es.filter(e=>e.status==='bloqueada').length+'
Pagas'+es.filter(e=>e.periodo_status==='pago').length+'
';rows.innerHTML=es.map(e=>{const bloqueada=e.status==='bloqueada'||e.periodo_status==='bloqueado';const liberar=bloqueada?' ':'';return ''+e.nome+''+Util.badge(e.status)+''+periodo(e)+'
'+liberar+''}).join('')}catch(e){Util.toast(e.message,true)}} +async function salvarVencimento(id,dia){try{await Api.put('/api/master/empresas/'+id+'/vencimento',{dia_vencimento:Number(dia)});Util.toast('Vencimento atualizado');load()}catch(e){Util.toast(e.message,true)}} +async function lembrar(id){try{await Api.post('/api/master/empresas/'+id+'/lembrete-cobranca',{canal:'email'});Util.toast('Lembrete registrado')}catch(e){Util.toast(e.message,true)}} +async function desbloquear(id){if(!confirm('Desbloquear manualmente esta empresa e liberar o periodo atual?'))return;try{await Api.post('/api/master/empresas/'+id+'/desbloquear',{});Util.toast('Empresa desbloqueada');load()}catch(e){Util.toast(e.message,true)}} +load(); diff --git a/src/main/webapp/master/planos.html b/src/main/webapp/master/planos.html new file mode 100644 index 0000000..4ba8ea5 --- /dev/null +++ b/src/main/webapp/master/planos.html @@ -0,0 +1 @@ +Planos

Planos

Novo plano

Planos cadastrados

IDNomeValorDescricaoStatus
\ No newline at end of file diff --git a/src/main/webapp/master/planos.js b/src/main/webapp/master/planos.js new file mode 100644 index 0000000..6c5043e --- /dev/null +++ b/src/main/webapp/master/planos.js @@ -0,0 +1 @@ +const form=document.querySelector('#plano-form'),rows=document.querySelector('#rows');async function load(){try{const d=await Api.get('/api/master/planos');rows.innerHTML=(d.planos||[]).map(p=>`${p.id}${p.nome}${Util.money(p.valor_mensal_centavos)}${p.descricao||''}${Util.badge(p.ativo?'ativo':'inativo')}`).join('')}catch(e){Util.toast(e.message,true)}}form.onsubmit=async e=>{e.preventDefault();const b=Util.formData(form);b.funcionalidades=(b.funcionalidades||'').split(',').map(x=>x.trim()).filter(Boolean);try{await Api.post('/api/master/planos',b);form.reset();Util.toast('Plano salvo');load()}catch(err){Util.toast(err.message,true)}};load(); \ No newline at end of file