commit c435bb5e23ecd6d3451337b9703d73c74feff12c Author: Tiago Date: Tue Sep 29 12:12:31 2026 -0300 Initial project import 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