Initial project import

This commit is contained in:
Tiago
2026-09-29 12:12:31 -03:00
commit c435bb5e23
120 changed files with 5371 additions and 0 deletions
@@ -0,0 +1,111 @@
package com.seconectai.condocontab;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.http.AuthHandlers;
import com.seconectai.condocontab.auth.http.AuthMiddleware;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.db.MigrationRunner;
import com.seconectai.condocontab.financeiro.AbacatePayClient;
import com.seconectai.condocontab.financeiro.AbacatePayWebhookHandler;
import com.seconectai.condocontab.financeiro.BloqueioJob;
import com.seconectai.condocontab.financeiro.FinanceiroHandlers;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.http.Router;
import com.seconectai.condocontab.http.StaticFileHandler;
import com.seconectai.condocontab.integracao.EmailClient;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.master.http.MasterHandlers;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.tenant.http.ClienteHandlers;
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.file.Path;
import java.sql.Connection;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public final class Main {
private Main() {
}
public static void main(String[] args) {
try {
AppConfig config = AppConfig.fromEnv();
DataSourceProvider dataSourceProvider = new DataSourceProvider(config);
dataSourceProvider.aguardarConexaoMaster(30, 1000);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
MigrationRunner.runMigrations(conn, "migrations/master");
}
JwtService jwtService = new JwtService(config.jwtSecret());
ApiKeyService apiKeyService = new ApiKeyService(dataSourceProvider);
AbacatePayClient abacatePayClient = new AbacatePayClient(config, dataSourceProvider);
EvolutionApiClient evolutionApiClient = new EvolutionApiClient(config);
EmailClient emailClient = new EmailClient();
AuthHandlers authHandlers = new AuthHandlers(config, dataSourceProvider, jwtService);
AuthMiddleware authMiddleware = new AuthMiddleware(jwtService, apiKeyService);
TenantDataSourceProvider tenantDataSourceProvider = new TenantDataSourceProvider(config, dataSourceProvider);
MasterHandlers masterHandlers = new MasterHandlers(dataSourceProvider, new TenantProvisioner(config), evolutionApiClient);
ClienteHandlers clienteHandlers = new ClienteHandlers(tenantDataSourceProvider, dataSourceProvider, Path.of("/app/uploads"), evolutionApiClient, emailClient);
FinanceiroHandlers financeiroHandlers = new FinanceiroHandlers(dataSourceProvider, abacatePayClient);
AbacatePayWebhookHandler abacatePayWebhookHandler = new AbacatePayWebhookHandler(dataSourceProvider, abacatePayClient);
if ("worker".equalsIgnoreCase(System.getenv().getOrDefault("APP_MODE", "web"))) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
try {
int processados = clienteHandlers.processarFilaEnvios();
if (processados > 0) System.out.println("Mensageiro processou " + processados + " mensagens");
} catch (Exception e) {
e.printStackTrace(System.err);
}
}, 2, 5, TimeUnit.SECONDS);
System.out.println("Mensageiro de envios iniciado");
new CountDownLatch(1).await();
return;
}
Router router = new Router();
router.get("/health", Main::health);
router.post("/api/master/login", authHandlers.masterLogin());
router.post("/api/auth/login", authHandlers.funcionarioLogin());
router.post("/api/master/webhooks/abacatepay", abacatePayWebhookHandler);
router.prefix("/api/master/", authMiddleware.exigirMaster(masterHandlers));
router.prefix("/api/financeiro/", authMiddleware.exigirFuncionario(financeiroHandlers));
router.prefix("/api/", authMiddleware.exigirFuncionario(clienteHandlers));
router.fallback(new StaticFileHandler());
HttpServer server = HttpServer.create(new InetSocketAddress(config.httpPort()), 0);
server.createContext("/", router);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(new BloqueioJob(dataSourceProvider), 1, 24 * 60 * 60, TimeUnit.SECONDS);
System.out.println("Servidor HTTP iniciado na porta " + config.httpPort());
} catch (Exception e) {
System.err.println("Falha ao iniciar aplicacao: " + e.getMessage());
e.printStackTrace(System.err);
System.exit(1);
}
}
private static void health(HttpExchange exchange) throws java.io.IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
HttpResponses.json(exchange, 200, Map.of("status", "ok"));
}
private static void apiNaoImplementada(HttpExchange exchange) throws java.io.IOException {
HttpResponses.json(exchange, 404, Map.of("erro", "endpoint_nao_implementado"));
}
}
@@ -0,0 +1,55 @@
package com.seconectai.condocontab.auth;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.OptionalLong;
public final class ApiKeyService {
private final DataSourceProvider dataSourceProvider;
private final SecureRandom secureRandom = new SecureRandom();
public ApiKeyService(DataSourceProvider dataSourceProvider) {
this.dataSourceProvider = dataSourceProvider;
}
public ChaveGerada gerar() {
byte[] random = new byte[32];
secureRandom.nextBytes(random);
String chave = "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
return new ChaveGerada(chave, hash(chave));
}
public OptionalLong validar(String chaveRecebida) throws SQLException {
if (chaveRecebida == null || chaveRecebida.isBlank()) {
return OptionalLong.empty();
}
String keyHash = hash(chaveRecebida);
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("SELECT empresa_id FROM api_keys WHERE key_hash = ? AND ativa = true")) {
ps.setString(1, keyHash);
try (var rs = ps.executeQuery()) {
return rs.next() ? OptionalLong.of(rs.getLong("empresa_id")) : OptionalLong.empty();
}
}
}
public static String hash(String chave) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(chave.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("Falha ao gerar hash de API Key", e);
}
}
public record ChaveGerada(String chave, String hash) {
}
}
@@ -0,0 +1,108 @@
package com.seconectai.condocontab.auth;
import com.seconectai.condocontab.util.Json;
import com.seconectai.condocontab.util.SimpleJson;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public final class JwtService {
private final byte[] secret;
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
private final Base64.Decoder decoder = Base64.getUrlDecoder();
public JwtService(String secret) {
if (secret == null || secret.isBlank()) {
throw new IllegalArgumentException("JWT_SECRET obrigatorio");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
public String gerar(Map<String, Object> claims, long validadeSegundos) {
Map<String, Object> header = Map.of("alg", "HS256", "typ", "JWT");
Map<String, Object> payload = new LinkedHashMap<>(claims);
payload.put("exp", Instant.now().getEpochSecond() + validadeSegundos);
String headerPart = encode(Json.write(header));
String payloadPart = encode(Json.write(payload));
String signingInput = headerPart + "." + payloadPart;
return signingInput + "." + encoder.encodeToString(hmac(signingInput));
}
public Optional<Map<String, Object>> validar(String token) {
try {
if (token == null) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
return Optional.empty();
}
String signingInput = parts[0] + "." + parts[1];
String expected = encoder.encodeToString(hmac(signingInput));
if (!constantTimeEquals(expected, parts[2])) {
return Optional.empty();
}
String payloadJson = new String(decoder.decode(parts[1]), StandardCharsets.UTF_8);
Map<String, String> parsed = SimpleJson.parseObject(payloadJson);
long exp = Long.parseLong(parsed.getOrDefault("exp", "0"));
if (Instant.now().getEpochSecond() >= exp) {
return Optional.empty();
}
Map<String, Object> claims = new LinkedHashMap<>();
parsed.forEach((k, v) -> claims.put(k, parseClaim(v)));
return Optional.of(claims);
} catch (Exception e) {
return Optional.empty();
}
}
private Object parseClaim(String value) {
if (value == null) {
return null;
}
if (value.matches("-?\\d+")) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return value;
}
}
if ("true".equals(value) || "false".equals(value)) {
return Boolean.parseBoolean(value);
}
return value;
}
private String encode(String text) {
return encoder.encodeToString(text.getBytes(StandardCharsets.UTF_8));
}
private byte[] hmac(String text) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Falha ao assinar JWT", e);
}
}
private boolean constantTimeEquals(String a, String b) {
byte[] left = a.getBytes(StandardCharsets.UTF_8);
byte[] right = b.getBytes(StandardCharsets.UTF_8);
if (left.length != right.length) {
return false;
}
int diff = 0;
for (int i = 0; i < left.length; i++) {
diff |= left[i] ^ right[i];
}
return diff == 0;
}
}
@@ -0,0 +1,19 @@
package com.seconectai.condocontab.auth;
import at.favre.lib.crypto.bcrypt.BCrypt;
public final class PasswordHasher {
private PasswordHasher() {
}
public static String hash(String senha) {
return BCrypt.withDefaults().hashToString(12, senha.toCharArray());
}
public static boolean verificar(String senha, String hash) {
if (senha == null || hash == null || hash.isBlank()) {
return false;
}
return BCrypt.verifyer().verify(senha.toCharArray(), hash.toCharArray()).verified;
}
}
@@ -0,0 +1,39 @@
package com.seconectai.condocontab.auth;
import java.time.Clock;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class RateLimiter {
private final Map<String, Deque<Long>> tentativas = new ConcurrentHashMap<>();
private final int maxTentativas;
private final long janelaMillis;
private final Clock clock;
public RateLimiter(int maxTentativas, long janelaMillis) {
this(maxTentativas, janelaMillis, Clock.systemUTC());
}
RateLimiter(int maxTentativas, long janelaMillis, Clock clock) {
this.maxTentativas = maxTentativas;
this.janelaMillis = janelaMillis;
this.clock = clock;
}
public boolean permitir(String chave) {
long agora = clock.millis();
Deque<Long> fila = tentativas.computeIfAbsent(chave, ignored -> new ArrayDeque<>());
synchronized (fila) {
while (!fila.isEmpty() && agora - fila.peekFirst() > janelaMillis) {
fila.removeFirst();
}
if (fila.size() >= maxTentativas) {
return false;
}
fila.addLast(agora);
return true;
}
}
}
@@ -0,0 +1,117 @@
package com.seconectai.condocontab.auth.http;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.PasswordHasher;
import com.seconectai.condocontab.auth.RateLimiter;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.master.dao.EmpresaDao;
import com.seconectai.condocontab.master.dao.FuncionarioDao;
import com.seconectai.condocontab.master.model.Empresa;
import com.seconectai.condocontab.master.model.Funcionario;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.util.LinkedHashMap;
import java.util.Map;
public final class AuthHandlers {
private static final long MASTER_VALIDADE_SEGUNDOS = 2 * 60 * 60;
private static final long FUNCIONARIO_VALIDADE_SEGUNDOS = 8 * 60 * 60;
private final AppConfig config;
private final DataSourceProvider dataSourceProvider;
private final JwtService jwtService;
private final RateLimiter rateLimiter;
public AuthHandlers(AppConfig config, DataSourceProvider dataSourceProvider, JwtService jwtService) {
this.config = config;
this.dataSourceProvider = dataSourceProvider;
this.jwtService = jwtService;
this.rateLimiter = new RateLimiter(5, 5 * 60 * 1000L);
}
public HttpHandler masterLogin() {
return exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
HttpResponses.methodNotAllowed(exchange);
return;
}
String ip = clientIp(exchange);
if (!rateLimiter.permitir("master:" + ip)) {
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
return;
}
Map<String, String> body = body(exchange);
String senha = body.get("senha");
if (!PasswordHasher.verificar(senha, config.masterPasswordHash())) {
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
return;
}
String token = jwtService.gerar(Map.of("escopo", "master"), MASTER_VALIDADE_SEGUNDOS);
HttpResponses.json(exchange, 200, Map.of("token", token));
};
}
public HttpHandler funcionarioLogin() {
return exchange -> {
if (!"POST".equals(exchange.getRequestMethod())) {
HttpResponses.methodNotAllowed(exchange);
return;
}
String ip = clientIp(exchange);
if (!rateLimiter.permitir("funcionario:" + ip)) {
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
return;
}
Map<String, String> body = body(exchange);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
Funcionario funcionario = new FuncionarioDao(conn).buscarPorEmail(body.get("email")).orElse(null);
if (funcionario == null || !PasswordHasher.verificar(body.get("senha"), funcionario.senhaHash())) {
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
return;
}
if (!"ativo".equals(funcionario.status())) {
HttpResponses.json(exchange, 403, Map.of("erro", "cadastro_desativado", "mensagem", "Cadastro de funcionario desativado."));
return;
}
Empresa empresa = new EmpresaDao(conn).buscarPorId(funcionario.empresaId()).orElse(null);
if (empresa == null || !("ativa".equals(empresa.status()) || "bloqueada".equals(empresa.status()))) {
HttpResponses.json(exchange, 403, Map.of("erro", "empresa_inativa", "mensagem", "Escritorio nao esta ativo."));
return;
}
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("escopo", "empresa");
claims.put("empresa_id", funcionario.empresaId());
claims.put("funcionario_id", funcionario.id());
claims.put("cargo", funcionario.cargo() == null ? "" : funcionario.cargo());
String token = jwtService.gerar(claims, FUNCIONARIO_VALIDADE_SEGUNDOS);
HttpResponses.json(exchange, 200, Map.of("token", token));
} catch (Exception e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_login"));
}
};
}
private Map<String, String> body(HttpExchange exchange) throws IOException {
String text = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
try {
return SimpleJson.parseObject(text);
} catch (IllegalArgumentException e) {
return Map.of();
}
}
private String clientIp(HttpExchange exchange) {
String forwarded = exchange.getRequestHeaders().getFirst("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,105 @@
package com.seconectai.condocontab.auth.http;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.auth.JwtService;
import com.seconectai.condocontab.auth.ApiKeyService;
import com.seconectai.condocontab.http.HttpResponses;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.sql.SQLException;
import java.util.Map;
public final class AuthMiddleware {
public static final String ATTR_EMPRESA_ID = "empresa_id";
public static final String ATTR_FUNCIONARIO_ID = "funcionario_id";
public static final String ATTR_CARGO = "cargo";
private final JwtService jwtService;
private final ApiKeyService apiKeyService;
public AuthMiddleware(JwtService jwtService, ApiKeyService apiKeyService) {
this.jwtService = jwtService;
this.apiKeyService = apiKeyService;
}
public HttpHandler exigirMaster(HttpHandler next) {
return exchange -> {
var claims = bearerClaims(exchange);
if (claims == null || !"master".equals(claims.get("escopo"))) {
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
return;
}
next.handle(exchange);
};
}
public HttpHandler exigirFuncionario(HttpHandler next) {
return exchange -> {
var claims = bearerClaims(exchange);
if (claims == null || !"empresa".equals(claims.get("escopo"))) {
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
return;
}
exchange.setAttribute(ATTR_EMPRESA_ID, toLong(claims.get("empresa_id")));
exchange.setAttribute(ATTR_FUNCIONARIO_ID, toLong(claims.get("funcionario_id")));
exchange.setAttribute(ATTR_CARGO, String.valueOf(claims.getOrDefault("cargo", "")));
Long empresaId = toLong(claims.get("empresa_id"));
if (empresaBloqueada(empresaId) && !exchange.getRequestURI().getPath().startsWith("/api/financeiro/")) {
HttpResponses.json(exchange, 403, Map.of("erro", "pendencia_financeira", "mensagem", "Seu acesso esta bloqueado por pendencia de pagamento."));
return;
}
next.handle(exchange);
};
}
public HttpHandler exigirApiKey(HttpHandler next) {
return exchange -> {
String chave = exchange.getRequestHeaders().getFirst("X-API-Key");
try {
var empresaId = apiKeyService.validar(chave);
if (empresaId.isEmpty()) {
HttpResponses.json(exchange, 401, Map.of("erro", "api_key_invalida"));
return;
}
exchange.setAttribute(ATTR_EMPRESA_ID, empresaId.getAsLong());
next.handle(exchange);
} catch (SQLException e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_validacao_api_key"));
}
};
}
private Map<String, Object> bearerClaims(HttpExchange exchange) throws IOException {
String auth = exchange.getRequestHeaders().getFirst("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
return null;
}
return jwtService.validar(auth.substring("Bearer ".length()).trim()).orElse(null);
}
private boolean empresaBloqueada(long empresaId) {
try (var conn = apiKeyServiceMasterConnection();
var ps = conn.prepareStatement("SELECT status FROM empresas WHERE id=?")) {
ps.setLong(1, empresaId);
try (var rs = ps.executeQuery()) { return rs.next() && "bloqueada".equals(rs.getString(1)); }
} catch (Exception e) { return false; }
}
private java.sql.Connection apiKeyServiceMasterConnection() throws java.sql.SQLException {
try {
var f = ApiKeyService.class.getDeclaredField("dataSourceProvider");
f.setAccessible(true);
var ds = (com.seconectai.condocontab.config.DataSourceProvider) f.get(apiKeyService);
return ds.getMasterConnection();
} catch (ReflectiveOperationException e) { throw new java.sql.SQLException(e); }
}
private long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
return Long.parseLong(String.valueOf(value));
}
}
@@ -0,0 +1,112 @@
package com.seconectai.condocontab.config;
import java.util.Map;
public final class AppConfig {
private final String dbHost;
private final int dbPort;
private final String dbUser;
private final String dbPassword;
private final String dbMasterName;
private final int httpPort;
private final String masterPasswordHash;
private final String jwtSecret;
private final String abacatePayApiKey;
private final String abacatePayWebhookSecret;
private final String appPublicUrl;
private final String evolutionGlobalApiKey;
private final String evolutionApiUrl;
private AppConfig(String dbHost, int dbPort, String dbUser, String dbPassword, String dbMasterName,
int httpPort, String masterPasswordHash, String jwtSecret, String abacatePayApiKey,
String abacatePayWebhookSecret, String appPublicUrl, String evolutionGlobalApiKey, String evolutionApiUrl) {
this.dbHost = dbHost;
this.dbPort = dbPort;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
this.dbMasterName = dbMasterName;
this.httpPort = httpPort;
this.masterPasswordHash = masterPasswordHash;
this.jwtSecret = jwtSecret;
this.abacatePayApiKey = abacatePayApiKey;
this.abacatePayWebhookSecret = abacatePayWebhookSecret;
this.appPublicUrl = appPublicUrl;
this.evolutionGlobalApiKey = evolutionGlobalApiKey;
this.evolutionApiUrl = evolutionApiUrl;
}
public static AppConfig fromEnv() {
Map<String, String> env = System.getenv();
return new AppConfig(
value(env, "DB_HOST", "localhost"),
intValue(env, "DB_PORT", 5432),
required(env, "DB_USER"),
required(env, "DB_PASSWORD"),
value(env, "DB_MASTER_NAME", "db_master"),
intValue(env, "HTTP_PORT", 8080),
required(env, "MASTER_PASSWORD_HASH"),
required(env, "JWT_SECRET"),
value(env, "ABACATEPAY_API_KEY", ""),
value(env, "ABACATEPAY_WEBHOOK_SECRET", "dev_webhook_secret"),
value(env, "APP_PUBLIC_URL", "http://localhost:8080"),
value(env, "EVOLUTION_GLOBAL_API_KEY", ""),
value(env, "EVOLUTION_API_URL", "mock://evolution")
);
}
private static String required(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Variavel de ambiente obrigatoria ausente: " + name);
}
return value;
}
private static String value(Map<String, String> env, String name, String defaultValue) {
String value = env.get(name);
return value == null || value.isBlank() ? defaultValue : value;
}
private static int intValue(Map<String, String> env, String name, int defaultValue) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return defaultValue;
}
return Integer.parseInt(value);
}
public String masterJdbcUrl() {
return jdbcUrl(dbMasterName);
}
public String postgresJdbcUrl() {
return jdbcUrl("postgres");
}
public String jdbcUrl(String databaseName) {
return "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + databaseName;
}
public String dbUser() {
return dbUser;
}
public String dbPassword() {
return dbPassword;
}
public int httpPort() {
return httpPort;
}
public String masterPasswordHash() {
return masterPasswordHash;
}
public String jwtSecret() { return jwtSecret; }
public String abacatePayApiKey() { return abacatePayApiKey; }
public String abacatePayWebhookSecret() { return abacatePayWebhookSecret; }
public String appPublicUrl() { return appPublicUrl; }
public String evolutionGlobalApiKey() { return evolutionGlobalApiKey; }
public String evolutionApiUrl() { return evolutionApiUrl; }
}
@@ -0,0 +1,43 @@
package com.seconectai.condocontab.config;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
public final class DataSourceProvider {
private final AppConfig config;
public DataSourceProvider(AppConfig config) {
this.config = config;
}
public Connection getMasterConnection() throws SQLException {
return DriverManager.getConnection(config.masterJdbcUrl(), config.dbUser(), config.dbPassword());
}
public void testarConexaoMaster() throws SQLException {
try (Connection conn = getMasterConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("SELECT 1");
System.out.println("Conexao com db_master OK");
}
}
public void aguardarConexaoMaster(int tentativas, long intervaloMillis) throws SQLException, InterruptedException {
SQLException ultimoErro = null;
for (int tentativa = 1; tentativa <= tentativas; tentativa++) {
try {
testarConexaoMaster();
return;
} catch (SQLException e) {
ultimoErro = e;
System.out.println("Aguardando db_master ficar disponivel (" + tentativa + "/" + tentativas + ")");
Thread.sleep(intervaloMillis);
}
}
throw ultimoErro;
}
// Prompt 03 troca este ponto por cache/pool por tenant via TenantDataSourceProvider.
}
@@ -0,0 +1,153 @@
package com.seconectai.condocontab.db;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
public final class MigrationRunner {
private MigrationRunner() {
}
public static void runMigrations(Connection conn, String diretorioResources) throws SQLException, IOException {
criarTabelaControle(conn);
List<String> migrations = listarMigrations(diretorioResources);
if (migrations.isEmpty()) {
System.out.println("Nenhuma migration encontrada em " + diretorioResources + " (no-op)");
return;
}
for (String migration : migrations) {
if (jaExecutada(conn, migration)) {
continue;
}
executarMigration(conn, diretorioResources, migration);
registrarExecucao(conn, migration);
System.out.println("Migration executada: " + migration);
}
}
private static void criarTabelaControle(Connection conn) throws SQLException {
try (Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
id BIGSERIAL PRIMARY KEY,
nome_arquivo VARCHAR(255) NOT NULL UNIQUE,
executado_em TIMESTAMP NOT NULL DEFAULT now()
)
""");
}
}
private static List<String> listarMigrations(String diretorioResources) throws IOException {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
URL url = classLoader.getResource(diretorioResources);
if (url == null) {
return List.of();
}
if ("file".equals(url.getProtocol())) {
try {
Path dir = Path.of(url.toURI());
try (Stream<Path> stream = Files.list(dir)) {
return stream
.filter(path -> path.getFileName().toString().endsWith(".sql"))
.map(path -> path.getFileName().toString())
.sorted()
.toList();
}
} catch (URISyntaxException e) {
throw new IOException("Diretorio de migrations invalido: " + diretorioResources, e);
}
}
if ("jar".equals(url.getProtocol())) {
return listarMigrationsNoJar(diretorioResources);
}
return List.of();
}
private static List<String> listarMigrationsNoJar(String diretorioResources) throws IOException {
List<String> migrations = new ArrayList<>();
String self = MigrationRunner.class.getProtectionDomain().getCodeSource().getLocation().getPath();
URI jarUri = URI.create("jar:file:" + self);
try (var fileSystem = java.nio.file.FileSystems.newFileSystem(jarUri, Collections.emptyMap())) {
Path dir = fileSystem.getPath(diretorioResources);
if (!Files.exists(dir)) {
return List.of();
}
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(path -> path.getFileName().toString().endsWith(".sql"))
.map(path -> path.getFileName().toString())
.sorted()
.forEach(migrations::add);
}
}
return migrations;
}
private static boolean jaExecutada(Connection conn, String nomeArquivo) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT 1 FROM schema_migrations WHERE nome_arquivo = ?")) {
ps.setString(1, nomeArquivo);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
private static void executarMigration(Connection conn, String diretorioResources, String nomeArquivo)
throws IOException, SQLException {
String resource = diretorioResources + "/" + nomeArquivo;
String sql = readResource(resource);
boolean autoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
try (Statement stmt = conn.createStatement()) {
stmt.execute(sql);
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(autoCommit);
}
}
private static String readResource(String resource) throws IOException {
InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (input == null) {
throw new IOException("Migration nao encontrada: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
StringBuilder sql = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sql.append(line).append('\n');
}
return sql.toString();
}
}
private static void registrarExecucao(Connection conn, String nomeArquivo) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO schema_migrations (nome_arquivo) VALUES (?)")) {
ps.setString(1, nomeArquivo);
ps.executeUpdate();
}
}
}
@@ -0,0 +1,88 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Base64;
public final class AbacatePayClient {
private final AppConfig config;
private final DataSourceProvider dataSourceProvider;
private final HttpClient http = HttpClient.newHttpClient();
public AbacatePayClient(AppConfig config, DataSourceProvider dataSourceProvider) {
this.config = config;
this.dataSourceProvider = dataSourceProvider;
}
public Cobranca criarCobranca(long valorCentavos, String descricao, String identificadorExterno) throws Exception {
String apiKey = setting("abacatepay_api_key", config.abacatePayApiKey());
if (apiKey.isBlank()) {
String id = "mock_" + identificadorExterno;
String payload = "000201 MOCK PIX " + valorCentavos + " " + identificadorExterno;
String base64 = Base64.getEncoder().encodeToString(("QR:" + payload).getBytes(StandardCharsets.UTF_8));
return new Cobranca(id, payload, base64);
}
String json = "{\"amount\":" + valorCentavos
+ ",\"description\":\"" + esc(descricao) + "\""
+ ",\"expiresIn\":86400"
+ ",\"metadata\":{\"externalId\":\"" + esc(identificadorExterno) + "\"}}";
HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.abacatepay.com/v1/pixQrCode/create"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() < 200 || res.statusCode() >= 300) {
throw new IllegalStateException("AbacatePay HTTP " + res.statusCode() + ": " + res.body());
}
String body = res.body();
return new Cobranca(find(body, "id"), find(body, "brcode"), find(body, "brcode_base64"));
}
public boolean validarWebhookSecret(String secret) {
try {
return setting("abacatepay_webhook_secret", config.abacatePayWebhookSecret()).equals(secret);
} catch (Exception e) {
return config.abacatePayWebhookSecret().equals(secret);
}
}
private String setting(String chave, String fallback) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT valor FROM configuracoes_plataforma WHERE chave=?")) {
ps.setString(1, chave);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String valor = rs.getString(1);
if (valor != null && !valor.isBlank()) return valor;
}
}
}
return fallback == null ? "" : fallback;
}
private String esc(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String find(String json, String key) {
String marker = "\"" + key + "\"";
int p = json.indexOf(marker);
if (p < 0) return null;
int c = json.indexOf(':', p);
int q = json.indexOf('"', c + 1);
int e = json.indexOf('"', q + 1);
return q < 0 || e < 0 ? null : json.substring(q + 1, e);
}
public record Cobranca(String chargeId, String qrCodePayload, String qrCodeBase64) {}
}
@@ -0,0 +1,3 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.DataSourceProvider;import com.seconectai.condocontab.http.HttpResponses;import com.seconectai.condocontab.util.SimpleJson;import com.sun.net.httpserver.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.sql.*;import java.util.*;
public final class AbacatePayWebhookHandler implements HttpHandler{private final DataSourceProvider ds;private final AbacatePayClient abacate;public AbacatePayWebhookHandler(DataSourceProvider ds,AbacatePayClient abacate){this.ds=ds;this.abacate=abacate;}public void handle(HttpExchange ex)throws IOException{try{String secret=query(ex).get("webhookSecret");if(!abacate.validarWebhookSecret(secret)){HttpResponses.json(ex,401,Map.of("erro","webhook_invalido"));return;}String body=new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);String charge=extractCharge(body);try(Connection c=ds.getMasterConnection()){Map<String,Object> p=one(c,"SELECT * FROM pagamentos WHERE abacatepay_charge_id=?",charge);if(p==null){HttpResponses.json(ex,404,Map.of("erro","pagamento_nao_encontrado"));return;}long pid=((Number)p.get("id")).longValue(), per=((Number)p.get("periodo_uso_id")).longValue(), emp=((Number)p.get("empresa_id")).longValue();try(PreparedStatement ps=c.prepareStatement("UPDATE pagamentos SET status='pago', pago_em=COALESCE(pago_em,now()) WHERE id=?")){ps.setLong(1,pid);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now() WHERE id=?")){ps.setLong(1,per);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")){ps.setLong(1,emp);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}}catch(Exception e){e.printStackTrace(System.err);HttpResponses.json(ex,500,Map.of("erro","erro_webhook"));}}private String extractCharge(String body){try{Map<String,String>m=SimpleJson.parseObject(body);for(String k:List.of("chargeId","id","abacatepay_charge_id"))if(m.get(k)!=null)return m.get(k);}catch(Exception ignored){}String[]keys={"id","chargeId"};for(String k:keys){String m="\""+k+"\"";int p=body.indexOf(m);if(p>=0){int c=body.indexOf(':',p),q=body.indexOf('"',c+1),e=body.indexOf('"',q+1);if(q>=0&&e>q)return body.substring(q+1,e);}}return null;}private Map<String,String>query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(kv[0],kv.length>1?kv[1]:"");}return q;}private Map<String,Object>one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.length;i++)ps.setObject(i+1,params[i]);try(ResultSet rs=ps.executeQuery()){if(!rs.next())return null;Map<String,Object>m=new HashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}}}}
@@ -0,0 +1,96 @@
package com.seconectai.condocontab.financeiro;
import com.seconectai.condocontab.config.DataSourceProvider;
import java.sql.*;
import java.time.LocalDate;
public final class BloqueioJob implements Runnable {
private final DataSourceProvider ds;
public BloqueioJob(DataSourceProvider ds) {
this.ds = ds;
}
public void run() {
try {
System.out.println("BloqueioJob bloqueou " + executarAgora() + " empresas");
} catch (Exception e) {
e.printStackTrace(System.err);
}
}
public int executarAgora() throws SQLException {
int n = 0;
try (Connection c = ds.getMasterConnection();
PreparedStatement ps = c.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status='ativa'")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long emp = rs.getLong(1);
long per = periodo(c, emp);
PeriodoStatus st = status(c, per);
if (LocalDate.now().isAfter(st.dataVencimento()) && !"pago".equals(st.status())) {
try (PreparedStatement u = c.prepareStatement("UPDATE periodos_uso SET status='bloqueado', data_bloqueio=now() WHERE id=?; UPDATE empresas SET status='bloqueada' WHERE id=?")) {
u.setLong(1, per);
u.setLong(2, emp);
u.execute();
n++;
}
}
}
}
}
return n;
}
private long periodo(Connection c, long emp) throws SQLException {
int dia = diaVencimento(c, emp);
LocalDate venc = vencimentoAtual(dia);
LocalDate inicio = venc.plusDays(1);
LocalDate fim = venc.plusMonths(1);
LocalDate mes = venc.withDayOfMonth(1);
try (PreparedStatement ps = c.prepareStatement("INSERT INTO periodos_uso (empresa_id,mes_referencia,data_inicio,data_fim,data_vencimento,status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id,mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")) {
ps.setLong(1, emp);
ps.setDate(2, java.sql.Date.valueOf(mes));
ps.setDate(3, java.sql.Date.valueOf(inicio));
ps.setDate(4, java.sql.Date.valueOf(fim));
ps.setDate(5, java.sql.Date.valueOf(venc));
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
}
private LocalDate vencimentoAtual(int dia) {
LocalDate hoje = LocalDate.now();
LocalDate base = hoje.withDayOfMonth(Math.min(dia, hoje.lengthOfMonth()));
if (hoje.isBefore(base)) {
LocalDate anterior = hoje.minusMonths(1);
base = anterior.withDayOfMonth(Math.min(dia, anterior.lengthOfMonth()));
}
return base;
}
private int diaVencimento(Connection c, long emp) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1")) {
ps.setLong(1, emp);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 7;
return Math.max(1, Math.min(28, rs.getInt(1)));
}
}
}
private PeriodoStatus status(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT status,data_vencimento FROM periodos_uso WHERE id=?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return new PeriodoStatus(rs.getString(1), rs.getDate(2).toLocalDate());
}
}
}
private record PeriodoStatus(String status, LocalDate dataVencimento) {}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
package com.seconectai.condocontab.http;
import com.seconectai.condocontab.util.Json;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class HttpResponses {
private HttpResponses() {
}
public static void json(HttpExchange exchange, int status, Map<String, Object> body) throws IOException {
json(exchange, status, Json.write(body));
}
public static void json(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(bytes);
}
}
public static void methodNotAllowed(HttpExchange exchange) throws IOException {
exchange.sendResponseHeaders(405, -1);
}
}
@@ -0,0 +1,59 @@
package com.seconectai.condocontab.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
public final class Router implements HttpHandler {
private final Map<String, HttpHandler> exactRoutes = new LinkedHashMap<>();
private final Map<String, HttpHandler> prefixRoutes = new LinkedHashMap<>();
private HttpHandler fallbackHandler;
public void get(String path, HttpHandler handler) {
route("GET", path, handler);
}
public void post(String path, HttpHandler handler) {
route("POST", path, handler);
}
public void route(String method, String path, HttpHandler handler) {
exactRoutes.put(method.toUpperCase() + " " + path, handler);
}
public void prefix(String prefix, HttpHandler handler) {
prefixRoutes.put(prefix, handler);
}
public void fallback(HttpHandler handler) {
this.fallbackHandler = handler;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String key = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
HttpHandler exact = exactRoutes.get(key);
if (exact != null) {
exact.handle(exchange);
return;
}
String path = exchange.getRequestURI().getPath();
for (Map.Entry<String, HttpHandler> entry : prefixRoutes.entrySet()) {
if (path.startsWith(entry.getKey())) {
entry.getValue().handle(exchange);
return;
}
}
if (fallbackHandler != null) {
fallbackHandler.handle(exchange);
return;
}
exchange.sendResponseHeaders(404, -1);
}
}
@@ -0,0 +1,65 @@
package com.seconectai.condocontab.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public final class StaticFileHandler implements HttpHandler {
private static final Map<String, String> CONTENT_TYPES = Map.of(
"html", "text/html; charset=utf-8",
"css", "text/css; charset=utf-8",
"js", "application/javascript; charset=utf-8",
"json", "application/json; charset=utf-8",
"png", "image/png"
);
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod()) && !"HEAD".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
return;
}
String path = URLDecoder.decode(exchange.getRequestURI().getPath(), StandardCharsets.UTF_8);
if (path.equals("/") || path.isBlank()) {
path = "/index.html";
}
if (path.contains("..")) {
exchange.sendResponseHeaders(400, -1);
return;
}
String resourcePath = "webapp" + path;
try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) {
if (input == null) {
exchange.sendResponseHeaders(404, -1);
return;
}
byte[] body = input.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", contentType(path));
if ("HEAD".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(200, -1);
return;
}
exchange.sendResponseHeaders(200, body.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(body);
}
}
}
private String contentType(String path) {
int dot = path.lastIndexOf('.');
if (dot < 0 || dot == path.length() - 1) {
return "application/octet-stream";
}
return CONTENT_TYPES.getOrDefault(path.substring(dot + 1), "application/octet-stream");
}
}
@@ -0,0 +1,36 @@
package com.seconectai.condocontab.integracao;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.PasswordAuthentication;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import java.util.Map;
import java.util.Properties;
public final class EmailClient {
public void enviar(Map<String, String> smtp, String destino, String assunto, String texto) throws MessagingException {
if (smtp == null || smtp.getOrDefault("smtp_host", "").isBlank()) return;
Properties props = new Properties();
props.put("mail.smtp.host", smtp.get("smtp_host"));
props.put("mail.smtp.port", smtp.getOrDefault("smtp_port", "587"));
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", smtp.getOrDefault("smtp_starttls", "true"));
String user = smtp.getOrDefault("smtp_user", "");
String pass = smtp.getOrDefault("smtp_password", "");
Session session = Session.getInstance(props, new jakarta.mail.Authenticator() {
@Override protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(user, pass);
}
});
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress(smtp.getOrDefault("smtp_from", user)));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(destino));
message.setSubject(assunto == null ? "Aviso" : assunto, "UTF-8");
message.setText(texto == null ? "" : texto, "UTF-8");
Transport.send(message);
}
}
@@ -0,0 +1,219 @@
package com.seconectai.condocontab.integracao;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.util.Json;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.qrcode.QRCodeWriter;
import java.io.IOException;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
public final class EvolutionApiClient {
private final String globalApiKey;
private final String defaultBaseUrl;
private final HttpClient httpClient;
public EvolutionApiClient(AppConfig config) {
this.globalApiKey = config.evolutionGlobalApiKey();
this.defaultBaseUrl = config.evolutionApiUrl();
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
public CriacaoInstancia criarInstancia(String baseUrl, String instanceName, String token, String numero) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
return new CriacaoInstancia(instanceName, token, qr, true);
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("instanceName", instanceName);
payload.put("integration", "WHATSAPP-BAILEYS");
payload.put("qrcode", true);
payload.put("number", onlyDigits(numero));
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/instance/create"))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json")
.header("apikey", globalApiKey)
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return new CriacaoInstancia(instanceName, firstNonBlank(extractJsonString(resp.body(), "hash"), token), qrBase64(resp.body()), false);
}
public Map<String, Object> conectar(String baseUrl, String instanceName, String numero) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
return Map.of("instance_name", instanceName, "qrcode_base64", qr, "mock", true);
}
String url = clean(baseUrl) + "/instance/connect/" + instanceName;
String digits = onlyDigits(numero);
if (!digits.isBlank()) {
url += "?number=" + digits;
}
HttpResponse<String> resp = sendNoBody("GET", url, globalApiKey);
Map<String, Object> out = new LinkedHashMap<>();
out.put("instance_name", instanceName);
out.put("pairing_code", extractJsonString(resp.body(), "pairingCode"));
out.put("qr_code_payload", extractJsonString(resp.body(), "code"));
out.put("qr_code_base64", qrBase64(resp.body()));
out.put("raw", resp.body());
return out;
}
public Map<String, Object> logout(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("DELETE", baseUrl, "/instance/logout/" + instanceName, instanceName);
}
public Map<String, Object> excluirInstancia(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("DELETE", baseUrl, "/instance/delete/" + instanceName, instanceName);
}
public Map<String, Object> reiniciar(String baseUrl, String instanceName) throws IOException, InterruptedException {
return instanceAction("POST", baseUrl, "/instance/restart/" + instanceName, instanceName);
}
public Map<String, Object> estado(String baseUrl, String instanceName) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
return Map.of("instance_name", instanceName, "state", "mock");
}
HttpResponse<String> resp = sendNoBody("GET", clean(baseUrl) + "/instance/connectionState/" + instanceName, globalApiKey);
Map<String, Object> out = new LinkedHashMap<>();
out.put("instance_name", instanceName);
out.put("state", firstNonBlank(extractJsonString(resp.body(), "state"), extractJsonString(resp.body(), "connectionStatus")));
out.put("raw", resp.body());
return out;
}
private Map<String, Object> instanceAction(String method, String baseUrl, String path, String instanceName) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
return Map.of("instance_name", instanceName, "ok", true, "mock", true);
}
HttpResponse<String> resp = sendNoBody(method, clean(baseUrl) + path, globalApiKey);
return Map.of("instance_name", instanceName, "ok", true, "raw", resp.body());
}
private HttpResponse<String> sendNoBody(String method, String url, String apiKey) throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(20))
.header("apikey", apiKey);
if ("GET".equals(method)) {
builder.GET();
} else if ("DELETE".equals(method)) {
builder.DELETE();
} else {
builder.method(method, HttpRequest.BodyPublishers.noBody());
}
HttpResponse<String> resp = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return resp;
}
public String enviarTexto(String baseUrl, String instanceName, String token, String numero, String texto) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) return "mock";
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("number", onlyDigits(numero));
payload.put("text", texto);
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/message/sendText/" + instanceName))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/json")
.header("apikey", globalApiKey)
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return resp.body();
}
private boolean mockMode(String baseUrl) {
return globalApiKey == null || globalApiKey.isBlank() || baseUrl == null || baseUrl.isBlank() || baseUrl.startsWith("mock://");
}
private String clean(String baseUrl) {
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
public static String onlyDigits(String raw) {
return raw == null ? "" : raw.replaceAll("\\D", "");
}
private String firstNonBlank(String first, String fallback) {
return first == null || first.isBlank() ? fallback : first;
}
private String qrBase64(String responseBody) throws IOException {
String image = firstNonBlank(extractJsonString(responseBody, "base64"), extractJsonString(responseBody, "qrcode"));
if (looksLikeImageBase64(image)) {
return stripDataImagePrefix(image);
}
String payload = extractJsonString(responseBody, "code");
if (payload == null || payload.isBlank()) {
return null;
}
try {
var matrix = new QRCodeWriter().encode(payload, BarcodeFormat.QR_CODE, 320, 320);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
MatrixToImageWriter.writeToStream(matrix, "PNG", out);
return Base64.getEncoder().encodeToString(out.toByteArray());
}
} catch (Exception e) {
throw new IOException("Falha ao gerar QR Code local", e);
}
}
private boolean looksLikeImageBase64(String value) {
if (value == null || value.isBlank()) return false;
String clean = stripDataImagePrefix(value);
return clean.startsWith("iVBOR") || clean.startsWith("/9j/") || clean.startsWith("PHN2Zy");
}
private String stripDataImagePrefix(String value) {
if (value == null) return null;
int comma = value.indexOf(',');
return value.startsWith("data:image/") && comma > 0 ? value.substring(comma + 1) : value;
}
private String extractJsonString(String raw, String key) {
String marker = "\"" + key + "\"";
int p = raw.indexOf(marker);
if (p < 0) return null;
int colon = raw.indexOf(':', p);
int first = raw.indexOf('"', colon + 1);
int second = raw.indexOf('"', first + 1);
if (first < 0 || second < 0) return null;
return raw.substring(first + 1, second);
}
public record CriacaoInstancia(String instanceName, String token, String qrcodeBase64, boolean mock) {}
}
@@ -0,0 +1,82 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Empresa;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class EmpresaDao {
private final Connection conn;
public EmpresaDao(Connection conn) {
this.conn = conn;
}
public Empresa inserir(Empresa empresa) throws SQLException {
String sql = """
INSERT INTO empresas (nome, cnpj, slug, database_name, status)
VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, empresa.nome());
ps.setString(2, empresa.cnpj());
ps.setString(3, empresa.slug());
ps.setString(4, empresa.databaseName());
ps.setString(5, empresa.status() == null ? "ativa" : empresa.status());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para empresa");
}
return buscarPorId(keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Empresa> buscarPorId(long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Empresa> listarTodas() throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas ORDER BY id")) {
try (ResultSet rs = ps.executeQuery()) {
List<Empresa> empresas = new ArrayList<>();
while (rs.next()) {
empresas.add(map(rs));
}
return empresas;
}
}
}
public void atualizarStatus(long id, String status) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status = ? WHERE id = ?")) {
ps.setString(1, status);
ps.setLong(2, id);
ps.executeUpdate();
}
}
private Empresa map(ResultSet rs) throws SQLException {
return new Empresa(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("cnpj"),
rs.getString("slug"),
rs.getString("database_name"),
rs.getString("status"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,49 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Funcionalidade;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class FuncionalidadeDao {
private final Connection conn;
public FuncionalidadeDao(Connection conn) {
this.conn = conn;
}
public List<Funcionalidade> listarTodas() throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades ORDER BY chave")) {
try (ResultSet rs = ps.executeQuery()) {
List<Funcionalidade> funcionalidades = new ArrayList<>();
while (rs.next()) {
funcionalidades.add(map(rs));
}
return funcionalidades;
}
}
}
public Optional<Funcionalidade> buscarPorChave(String chave) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades WHERE chave = ?")) {
ps.setString(1, chave);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Funcionalidade map(ResultSet rs) throws SQLException {
return new Funcionalidade(
rs.getLong("id"),
rs.getString("chave"),
rs.getString("nome"),
rs.getString("descricao")
);
}
}
@@ -0,0 +1,88 @@
package com.seconectai.condocontab.master.dao;
import com.seconectai.condocontab.master.model.Funcionario;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class FuncionarioDao {
private final Connection conn;
public FuncionarioDao(Connection conn) {
this.conn = conn;
}
public Funcionario inserir(Funcionario funcionario) throws SQLException {
String sql = """
INSERT INTO funcionarios (empresa_id, nome, email, telefone, senha_hash, cargo, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, funcionario.empresaId());
ps.setString(2, funcionario.nome());
ps.setString(3, funcionario.email());
ps.setString(4, funcionario.telefone());
ps.setString(5, funcionario.senhaHash());
ps.setString(6, funcionario.cargo());
ps.setString(7, funcionario.status() == null ? "ativo" : funcionario.status());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para funcionario");
}
return buscarPorId(keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Funcionario> buscarPorEmail(String email) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE email = ?")) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Funcionario> listarPorEmpresa(long empresaId) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE empresa_id = ? ORDER BY id")) {
ps.setLong(1, empresaId);
try (ResultSet rs = ps.executeQuery()) {
List<Funcionario> funcionarios = new ArrayList<>();
while (rs.next()) {
funcionarios.add(map(rs));
}
return funcionarios;
}
}
}
private Optional<Funcionario> buscarPorId(long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Funcionario map(ResultSet rs) throws SQLException {
return new Funcionario(
rs.getLong("id"),
rs.getLong("empresa_id"),
rs.getString("nome"),
rs.getString("email"),
rs.getString("telefone"),
rs.getString("senha_hash"),
rs.getString("cargo"),
rs.getString("status"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,519 @@
package com.seconectai.condocontab.master.http;
import com.seconectai.condocontab.auth.PasswordHasher;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.master.dao.EmpresaDao;
import com.seconectai.condocontab.master.dao.FuncionarioDao;
import com.seconectai.condocontab.master.model.Empresa;
import com.seconectai.condocontab.master.model.Funcionario;
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.time.LocalDate;
import java.util.*;
public final class MasterHandlers implements HttpHandler {
private final DataSourceProvider dataSourceProvider;
private final TenantProvisioner tenantProvisioner;
private final EvolutionApiClient evolutionApiClient;
public MasterHandlers(DataSourceProvider dataSourceProvider, TenantProvisioner tenantProvisioner, EvolutionApiClient evolutionApiClient) {
this.dataSourceProvider = dataSourceProvider;
this.tenantProvisioner = tenantProvisioner;
this.evolutionApiClient = evolutionApiClient;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
route(exchange);
} catch (IllegalArgumentException e) {
HttpResponses.json(exchange, 400, Map.of("erro", "requisicao_invalida", "mensagem", e.getMessage()));
} catch (SQLException e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_banco", "mensagem", e.getMessage()));
} catch (Exception e) {
HttpResponses.json(exchange, 500, Map.of("erro", "erro_interno", "mensagem", e.getMessage()));
}
}
private void route(HttpExchange ex) throws Exception {
String method = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
String rest = path.substring("/api/master".length());
if (rest.isEmpty()) rest = "/";
String[] parts = Arrays.stream(rest.split("/"))
.filter(s -> !s.isBlank())
.map(s -> URLDecoder.decode(s, StandardCharsets.UTF_8))
.toArray(String[]::new);
if (parts.length == 1 && "empresas".equals(parts[0])) {
if ("POST".equals(method)) criarEmpresa(ex);
else if ("GET".equals(method)) listarEmpresas(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 2 && "empresas".equals(parts[0])) {
long id = parseId(parts[1]);
if ("GET".equals(method)) obterEmpresa(ex, id);
else if ("PUT".equals(method)) atualizarEmpresa(ex, id);
else if ("DELETE".equals(method)) arquivarEmpresa(ex, id);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionarios".equals(parts[2])) {
long id = parseId(parts[1]);
if ("POST".equals(method)) criarFuncionario(ex, id);
else if ("GET".equals(method)) listarFuncionarios(ex, id);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "GET".equals(method)) {
listarFuncionalidadesEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 5 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "POST".equals(method)) {
alterarFuncionalidadeEmpresa(ex, parseId(parts[1]), parts[3], "ativar".equals(parts[4]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "lembrete-cobranca".equals(parts[2]) && "POST".equals(method)) {
registrarLembrete(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "vencimento".equals(parts[2]) && "PUT".equals(method)) {
atualizarVencimentoEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "status".equals(parts[2]) && "POST".equals(method)) {
alterarStatusEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "desbloquear".equals(parts[2]) && "POST".equals(method)) {
desbloquearEmpresa(ex, parseId(parts[1]));
return;
}
if (parts.length == 3 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "GET".equals(method)) {
listarEvolutionInstancias(ex, parseId(parts[1]));
return;
}
if (parts.length == 5 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "POST".equals(method)) {
alterarEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]), "aprovar".equals(parts[4]) ? "aprovada" : "rejeitada");
return;
}
if (parts.length == 4 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "DELETE".equals(method)) {
excluirEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]));
return;
}
if (parts.length == 1 && "configuracoes".equals(parts[0])) {
if ("GET".equals(method)) listarConfiguracoes(ex);
else if ("PUT".equals(method)) salvarConfiguracoes(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 1 && "planos".equals(parts[0])) {
if ("GET".equals(method)) listarPlanos(ex);
else if ("POST".equals(method)) criarPlano(ex);
else HttpResponses.methodNotAllowed(ex);
return;
}
if (parts.length == 2 && "planos".equals(parts[0]) && "PUT".equals(method)) {
atualizarPlano(ex, parseId(parts[1]));
return;
}
if (parts.length == 1 && "logs-envio".equals(parts[0]) && "GET".equals(method)) {
listarLogsEnvio(ex);
return;
}
HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado"));
}
private void listarEvolutionInstancias(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, numero_whatsapp, status, solicitado_em, aprovado_em, aprovado_por FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC, id DESC")) {
ps.setLong(1, empresaId);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(resultMap(rs)); }
HttpResponses.json(ex, 200, Map.of("instancias", rows));
}
}
private void excluirEvolutionInstancia(HttpExchange ex, long empresaId, long instId) throws Exception {
Map<String,Object> inst;
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, instance_token_enc, numero_whatsapp FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
ps.setLong(1, instId);
ps.setLong(2, empresaId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada"));
return;
}
inst = resultMap(rs);
}
}
String instanceToken = String.valueOf(inst.get("instance_token_enc"));
String instanceName = instanceToken.split(":", 2)[0];
String evolutionErro = null;
try {
evolutionApiClient.excluirInstancia(String.valueOf(inst.get("instance_url")), instanceName);
} catch (Exception err) {
evolutionErro = err.getMessage();
String msg = evolutionErro == null ? "" : evolutionErro.toLowerCase(Locale.ROOT);
if (!(msg.contains("404") || msg.contains("not found") || msg.contains("não encontrada") || msg.contains("nao encontrada") || msg.contains("instance not found"))) {
HttpResponses.json(ex, 502, Map.of("erro", "falha_excluir_evolution", "mensagem", evolutionErro));
return;
}
}
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("DELETE FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
ps.setLong(1, instId);
ps.setLong(2, empresaId);
ps.executeUpdate();
}
Map<String,Object> resp = new LinkedHashMap<>();
resp.put("ok", true);
resp.put("evolution_instance", instanceName);
if (evolutionErro != null) resp.put("aviso", "Registro local removido; a Evolution informou que a instancia ja nao existia: " + evolutionErro);
HttpResponses.json(ex, 200, resp);
}
private void alterarEvolutionInstancia(HttpExchange ex, long empresaId, long instId, String status) throws Exception {
if (!"aprovada".equals(status) && !"rejeitada".equals(status)) { HttpResponses.json(ex, 400, Map.of("erro", "acao_invalida")); return; }
String sql = "UPDATE evolution_instancias SET status=?, aprovado_em=CASE WHEN ?='aprovada' THEN now() ELSE aprovado_em END, aprovado_por=NULL WHERE id=? AND empresa_id=?";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
ps.setString(1, status); ps.setString(2, status); ps.setLong(3, instId); ps.setLong(4, empresaId);
if (ps.executeUpdate() == 0) { conn.rollback(); HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada")); return; }
if ("aprovada".equals(status)) ativarFuncionalidade(conn, empresaId, "whatsapp_envio");
conn.commit();
listarEvolutionInstancias(ex, empresaId);
}
}
private void ativarFuncionalidade(Connection conn, long empresaId, String chave) throws SQLException {
String sql = """
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
SELECT ?, id, true, now(), NULL FROM funcionalidades WHERE chave=?
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=true, liberado_em=now()
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId);
ps.setString(2, chave);
ps.executeUpdate();
}
}
private void listarConfiguracoes(HttpExchange ex) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT chave, valor, atualizado_em FROM configuracoes_plataforma ORDER BY chave")) {
Map<String,Object> cfg = new LinkedHashMap<>();
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String chave = rs.getString("chave");
Object valor = Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) ? "********" : rs.getString("valor");
cfg.put(chave, valor);
Map<String,Object> row = new LinkedHashMap<>(); row.put("chave", chave); row.put("valor", valor); row.put("atualizado_em", rs.getObject("atualizado_em")); rows.add(row);
}
}
HttpResponses.json(ex, 200, Map.of("configuracoes", cfg, "itens", rows));
}
}
private void salvarConfiguracoes(HttpExchange ex) throws Exception {
Map<String,String> b = body(ex);
Set<String> permitidas = Set.of("smtp_host","smtp_port","smtp_user","smtp_password","smtp_from","smtp_starttls","abacatepay_api_key","abacatepay_webhook_secret");
try (Connection conn = dataSourceProvider.getMasterConnection()) {
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO configuracoes_plataforma (chave, valor, atualizado_em) VALUES (?, ?, now()) ON CONFLICT (chave) DO UPDATE SET valor=EXCLUDED.valor, atualizado_em=now()")) {
for (String chave : permitidas) {
if (!b.containsKey(chave)) continue;
String valor = b.get(chave);
if (Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) && (valor == null || valor.isBlank() || "********".equals(valor))) continue;
ps.setString(1, chave); ps.setString(2, valor); ps.addBatch();
}
ps.executeBatch();
}
}
listarConfiguracoes(ex);
}
private void criarEmpresa(HttpExchange ex) throws Exception {
Map<String, String> body = body(ex);
String nome = required(body, "nome");
String cnpj = body.get("cnpj");
String slug = required(body, "slug");
Long planoId = optionalLong(body.get("plano_id"));
String emailPrincipal = required(body, "email");
String senhaPrincipal = required(body, "senha");
String telefonePrincipal = body.get("telefone");
String dbName = tenantProvisioner.databaseNameFromSlug(slug);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
if (exists(conn, "SELECT 1 FROM empresas WHERE slug = ?", slug.replace('-', '_'))) {
HttpResponses.json(ex, 409, Map.of("erro", "slug_duplicado"));
return;
}
if (exists(conn, "SELECT 1 FROM funcionarios WHERE email = ?", emailPrincipal)) {
HttpResponses.json(ex, 409, Map.of("erro", "email_duplicado", "mensagem", "Este e-mail ja esta cadastrado como login."));
return;
}
EmpresaDao dao = new EmpresaDao(conn);
Empresa criada = dao.inserir(new Empresa(null, nome, cnpj, slug.replace('-', '_'), dbName, "provisionando", null));
var resultado = tenantProvisioner.provisionar(nome, slug);
String status = resultado.sucesso() ? "ativa" : "provisionamento_pendente";
dao.atualizarStatus(criada.id(), status);
if (planoId != null) { salvarAssinatura(conn, criada.id(), planoId); copiarFuncionalidadesPlano(conn, criada.id(), planoId); }
Funcionario principal = new FuncionarioDao(conn).inserir(new Funcionario(null, criada.id(), nome, emailPrincipal, telefonePrincipal, PasswordHasher.hash(senhaPrincipal), "admin_empresa", "ativo", null));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("empresa", empresaMap(dao.buscarPorId(criada.id()).orElseThrow()));
resp.put("funcionario_principal", funcionarioMap(principal));
resp.put("provisionamento", resultado.sucesso());
resp.put("erro", resultado.erro());
HttpResponses.json(ex, 201, resp);
}
}
private void listarEmpresas(HttpExchange ex) throws Exception {
Map<String, String> q = query(ex);
StringBuilder sql = new StringBuilder("""
SELECT e.*, p.id AS plano_id, p.nome AS plano_nome, p.valor_mensal_centavos AS plano_valor_centavos, a.dia_vencimento, pu.status AS periodo_status, pu.mes_referencia, pu.data_inicio, pu.data_fim, pu.data_vencimento
FROM empresas e
LEFT JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa'
LEFT JOIN planos p ON p.id=a.plano_id
LEFT JOIN LATERAL (SELECT * FROM periodos_uso pu WHERE pu.empresa_id=e.id ORDER BY pu.mes_referencia DESC LIMIT 1) pu ON true
WHERE 1=1
""");
List<Object> params = new ArrayList<>();
if (q.containsKey("status")) { sql.append(" AND e.status=?"); params.add(q.get("status")); } else { sql.append(" AND e.status<>'excluida'"); }
if ("pagante".equals(q.get("situacao"))) { sql.append(" AND pu.status='pago'"); }
sql.append(" ORDER BY e.id");
try (Connection conn = dataSourceProvider.getMasterConnection()) {
garantirPeriodosAtuais(conn);
try (PreparedStatement ps = conn.prepareStatement(sql.toString())) {
bind(ps, params);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(empresaResumoMap(rs)); }
HttpResponses.json(ex, 200, Map.of("empresas", rows));
}
}
}
private void obterEmpresa(HttpExchange ex, long id) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
var emp = new EmpresaDao(conn).buscarPorId(id);
if (emp.isEmpty()) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
Map<String,Object> out = empresaMap(emp.get());
Map<String,Object> plano = one(conn, "SELECT p.id,p.nome,p.valor_mensal_centavos FROM assinaturas a JOIN planos p ON p.id=a.plano_id WHERE a.empresa_id=? AND a.status='ativa' ORDER BY a.id DESC LIMIT 1", List.of(id));
out.put("plano", plano);
HttpResponses.json(ex, 200, Map.of("empresa", out));
}
}
private void atualizarEmpresa(HttpExchange ex, long id) throws Exception {
Map<String,String> b = body(ex);
Long planoId = optionalLong(b.get("plano_id"));
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj) WHERE id=?")) {
ps.setString(1, emptyToNull(b.get("nome"))); ps.setString(2, emptyToNull(b.get("cnpj"))); ps.setLong(3, id); ps.executeUpdate();
}
if (planoId != null) { salvarAssinatura(conn, id, planoId); copiarFuncionalidadesPlano(conn, id, planoId); }
conn.commit();
obterEmpresa(ex, id);
}
}
private void arquivarEmpresa(HttpExchange ex, long id) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='excluida' WHERE id=?")) {
ps.setLong(1, id);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
HttpResponses.json(ex, 200, Map.of("ok", true, "status", "excluida"));
}
}
private void criarFuncionario(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b = body(ex);
try (Connection conn = dataSourceProvider.getMasterConnection()) {
Funcionario f = new FuncionarioDao(conn).inserir(new Funcionario(null, empresaId, required(b,"nome"), required(b,"email"), b.get("telefone"), PasswordHasher.hash(required(b,"senha")), b.get("cargo"), "ativo", null));
HttpResponses.json(ex, 201, Map.of("funcionario", funcionarioMap(f)));
}
}
private void listarFuncionarios(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
List<Map<String,Object>> rows = new ArrayList<>();
for (Funcionario f : new FuncionarioDao(conn).listarPorEmpresa(empresaId)) rows.add(funcionarioMap(f));
HttpResponses.json(ex, 200, Map.of("funcionarios", rows));
}
}
private void listarFuncionalidadesEmpresa(HttpExchange ex, long empresaId) throws Exception {
String sql = """
SELECT f.chave, f.nome, f.descricao, COALESCE(ef.ativa,false) AS ativa
FROM funcionalidades f
LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=?
ORDER BY f.chave
""";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId);
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(Map.of("chave", rs.getString("chave"), "nome", rs.getString("nome"), "descricao", rs.getString("descricao"), "ativa", rs.getBoolean("ativa"))); }
HttpResponses.json(ex, 200, Map.of("funcionalidades", rows));
}
}
private void alterarFuncionalidadeEmpresa(HttpExchange ex, long empresaId, String chave, boolean ativa) throws Exception {
String sql = """
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
SELECT ?, id, ?, now(), NULL FROM funcionalidades WHERE chave=?
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=EXCLUDED.ativa, liberado_em=now()
""";
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, empresaId); ps.setBoolean(2, ativa); ps.setString(3, chave);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "funcionalidade_nao_encontrada")); return; }
listarFuncionalidadesEmpresa(ex, empresaId);
}
}
private void criarPlano(HttpExchange ex) throws Exception { salvarPlano(ex, null); }
private void atualizarPlano(HttpExchange ex, long id) throws Exception { salvarPlano(ex, id); }
private void salvarPlano(HttpExchange ex, Long id) throws Exception {
String raw = rawBody(ex);
List<String> funcionalidades = stringArray(raw, "funcionalidades");
Map<String,String> b = SimpleJson.parseObject(removeArrayField(raw, "funcionalidades"));
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
long planoId = id == null ? inserirPlano(conn, b) : atualizarPlanoRow(conn, id, b);
sincronizarPlanoFuncionalidades(conn, planoId, funcionalidades);
conn.commit();
HttpResponses.json(ex, id == null ? 201 : 200, Map.of("plano", planoMap(conn, planoId)));
}
}
private void listarPlanos(HttpExchange ex) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM planos ORDER BY id")) {
List<Map<String,Object>> rows = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(planoMap(rs)); }
HttpResponses.json(ex, 200, Map.of("planos", rows));
}
}
private void listarLogsEnvio(HttpExchange ex) throws Exception {
Map<String,String> q=query(ex); int limit=(int)(optionalLong(q.get("limit"))==null?50:optionalLong(q.get("limit"))); int offset=(int)(optionalLong(q.get("offset"))==null?0:optionalLong(q.get("offset")));
StringBuilder sql=new StringBuilder("SELECT * FROM logs_envio_avisos WHERE 1=1"); List<Object> params=new ArrayList<>();
if(q.get("status")!=null){sql.append(" AND status=?");params.add(q.get("status"));}
if(q.get("empresa_id")!=null){sql.append(" AND empresa_id=?");params.add(Long.parseLong(q.get("empresa_id")));}
sql.append(" ORDER BY criado_em DESC LIMIT ? OFFSET ?"); params.add(limit); params.add(offset);
try(Connection conn=dataSourceProvider.getMasterConnection(); PreparedStatement ps=conn.prepareStatement(sql.toString())){ bind(ps,params); List<Map<String,Object>> rows=new ArrayList<>(); try(ResultSet rs=ps.executeQuery()){while(rs.next()) rows.add(resultMap(rs));} HttpResponses.json(ex,200,Map.of("logs",rows));}
}
private void atualizarVencimentoEmpresa(HttpExchange ex,long empresaId)throws Exception{
Map<String,String>b=body(ex);
int dia=Integer.parseInt(required(b,"dia_vencimento"));
if(dia<1||dia>28){HttpResponses.json(ex,400,Map.of("erro","dia_vencimento_invalido","mensagem","Informe um dia entre 1 e 28."));return;}
try(Connection conn=dataSourceProvider.getMasterConnection()){
conn.setAutoCommit(false);
try{
if(!exists(conn,"SELECT 1 FROM empresas WHERE id=?",empresaId)){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","empresa_nao_encontrada"));return;}
Map<String,Object>a=one(conn,"SELECT id FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));
if(a==null){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","assinatura_nao_encontrada","mensagem","Empresa sem assinatura ativa."));return;}
try(PreparedStatement ps=conn.prepareStatement("UPDATE assinaturas SET dia_vencimento=? WHERE id=?")){ps.setInt(1,dia);ps.setLong(2,((Number)a.get("id")).longValue());ps.executeUpdate();}
long periodo=periodoAtual(conn,empresaId);
conn.commit();
HttpResponses.json(ex,200,Map.of("ok",true,"empresa_id",empresaId,"dia_vencimento",dia,"periodo_uso_id",periodo));
}catch(Exception err){conn.rollback();throw err;}finally{conn.setAutoCommit(true);}
}
}
private void desbloquearEmpresa(HttpExchange ex, long empresaId) throws Exception {
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
try {
if (!exists(conn, "SELECT 1 FROM empresas WHERE id = ?", empresaId)) {
conn.rollback();
HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada"));
return;
}
long periodoId = periodoAtual(conn, empresaId);
try (PreparedStatement ps = conn.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now(), data_bloqueio=NULL WHERE id=?")) {
ps.setLong(1, periodoId);
ps.executeUpdate();
}
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")) {
ps.setLong(1, empresaId);
ps.executeUpdate();
}
conn.commit();
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "periodo_uso_id", periodoId, "status", "ativa", "liberacao", "manual"));
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
}
}
private void alterarStatusEmpresa(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b = body(ex);
String status = required(b, "status");
if (!Set.of("ativa", "bloqueada").contains(status)) {
HttpResponses.json(ex, 400, Map.of("erro", "status_invalido"));
return;
}
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status=? WHERE id=? AND status<>'excluida'")) {
ps.setString(1, status);
ps.setLong(2, empresaId);
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "status", status));
}
}
private void registrarLembrete(HttpExchange ex, long empresaId) throws Exception {
Map<String,String> b=body(ex); String canal=required(b,"canal");
try(Connection conn=dataSourceProvider.getMasterConnection()){
long periodoId=periodoAtual(conn,empresaId);
// TODO Prompt 08: enviar lembrete real por WhatsApp/e-mail; nesta fase apenas registra a intenção.
try(PreparedStatement ps=conn.prepareStatement("INSERT INTO lembretes_cobranca (empresa_id, periodo_uso_id, canal, enviado_por) VALUES (?,?,?,NULL) RETURNING id")){ps.setLong(1,empresaId);ps.setLong(2,periodoId);ps.setString(3,canal);try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"registrado",true));}}
}
}
private void garantirPeriodosAtuais(Connection conn)throws SQLException{try(PreparedStatement ps=conn.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status<>'excluida' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next())periodoAtual(conn,rs.getLong(1));}}}
private long periodoAtual(Connection conn,long empresaId)throws SQLException{int dia=diaVencimento(conn,empresaId);LocalDate venc=vencimentoAtual(dia);LocalDate inicio=venc.plusDays(1);LocalDate fim=venc.plusMonths(1);LocalDate mes=venc.withDayOfMonth(1);try(PreparedStatement ps=conn.prepareStatement("INSERT INTO periodos_uso (empresa_id, mes_referencia, data_inicio, data_fim, data_vencimento, status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id, mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")){ps.setLong(1,empresaId);ps.setDate(2,java.sql.Date.valueOf(mes));ps.setDate(3,java.sql.Date.valueOf(inicio));ps.setDate(4,java.sql.Date.valueOf(fim));ps.setDate(5,java.sql.Date.valueOf(venc));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private LocalDate vencimentoAtual(int dia){LocalDate hoje=LocalDate.now();LocalDate base=hoje.withDayOfMonth(Math.min(dia,hoje.lengthOfMonth()));if(hoje.isBefore(base)){LocalDate anterior=hoje.minusMonths(1);base=anterior.withDayOfMonth(Math.min(dia,anterior.lengthOfMonth()));}return base;}
private int diaVencimento(Connection conn,long empresaId)throws SQLException{Map<String,Object>a=one(conn,"SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));if(a==null||a.get("dia_vencimento")==null)return 7;int dia=((Number)a.get("dia_vencimento")).intValue();return Math.max(1,Math.min(28,dia));}
private long inserirPlano(Connection c, Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO planos (nome,valor_mensal_centavos,descricao,ativo) VALUES (?,?,?,true) RETURNING id")){ps.setString(1,required(b,"nome"));ps.setLong(2,Long.parseLong(required(b,"valor_mensal_centavos")));ps.setString(3,b.get("descricao"));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long atualizarPlanoRow(Connection c,long id,Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE planos SET nome=COALESCE(?,nome), valor_mensal_centavos=COALESCE(?,valor_mensal_centavos), descricao=COALESCE(?,descricao) WHERE id=?")){ps.setString(1,b.get("nome")); if(b.get("valor_mensal_centavos")==null)ps.setNull(2,Types.BIGINT);else ps.setLong(2,Long.parseLong(b.get("valor_mensal_centavos"))); ps.setString(3,b.get("descricao")); ps.setLong(4,id); ps.executeUpdate(); return id;}}
private void sincronizarPlanoFuncionalidades(Connection c,long planoId,List<String> chaves)throws SQLException{try(PreparedStatement del=c.prepareStatement("DELETE FROM plano_funcionalidades WHERE plano_id=?")){del.setLong(1,planoId);del.executeUpdate();}try(PreparedStatement ins=c.prepareStatement("INSERT INTO plano_funcionalidades (plano_id,funcionalidade_id) SELECT ?, id FROM funcionalidades WHERE chave=?")){for(String chave:chaves){ins.setLong(1,planoId);ins.setString(2,chave);ins.addBatch();}ins.executeBatch();}}
private void copiarFuncionalidadesPlano(Connection c,long empresaId,long planoId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO empresa_funcionalidades (empresa_id,funcionalidade_id,ativa) SELECT ?, funcionalidade_id, true FROM plano_funcionalidades WHERE plano_id=? ON CONFLICT (empresa_id,funcionalidade_id) DO UPDATE SET ativa=true")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.executeUpdate();}}
private void salvarAssinatura(Connection c,long empresaId,long planoId)throws SQLException{Map<String,Object> p=one(c,"SELECT valor_mensal_centavos FROM planos WHERE id=?",List.of(planoId));if(p==null)throw new IllegalArgumentException("Plano nao encontrado");try(PreparedStatement ps=c.prepareStatement("UPDATE assinaturas SET status='inativa' WHERE empresa_id=? AND status='ativa'")){ps.setLong(1,empresaId);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO assinaturas (empresa_id,plano_id,valor_mensal_centavos,dia_vencimento,status) VALUES (?,?,?,?, 'ativa')")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.setLong(3,((Number)p.get("valor_mensal_centavos")).longValue());ps.setInt(4,7);ps.executeUpdate();}}
private Map<String,Object> empresaMap(Empresa e){Map<String,Object> m=new LinkedHashMap<>();m.put("id",e.id());m.put("nome",e.nome());m.put("cnpj",e.cnpj());m.put("slug",e.slug());m.put("database_name",e.databaseName());m.put("status",e.status());m.put("criado_em",e.criadoEm());return m;}
private Map<String,Object> funcionarioMap(Funcionario f){Map<String,Object> m=new LinkedHashMap<>();m.put("id",f.id());m.put("empresa_id",f.empresaId());m.put("nome",f.nome());m.put("email",f.email());m.put("telefone",f.telefone());m.put("cargo",f.cargo()==null?"":f.cargo());m.put("status",f.status());return m;}
private Map<String,Object> empresaResumoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("cnpj",rs.getString("cnpj"));m.put("slug",rs.getString("slug"));m.put("database_name",rs.getString("database_name"));m.put("status",rs.getString("status"));m.put("plano_id",rs.getObject("plano_id"));m.put("plano",rs.getString("plano_nome"));m.put("plano_valor_centavos",rs.getObject("plano_valor_centavos"));m.put("dia_vencimento",rs.getObject("dia_vencimento"));m.put("periodo_status",rs.getString("periodo_status"));m.put("mes_referencia",rs.getObject("mes_referencia"));m.put("data_inicio",rs.getObject("data_inicio"));m.put("data_fim",rs.getObject("data_fim"));m.put("data_vencimento",rs.getObject("data_vencimento"));return m;}
private Map<String,Object> planoMap(Connection c,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement("SELECT * FROM planos WHERE id=?")){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return planoMap(rs);}}}
private Map<String,Object> planoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("valor_mensal_centavos",rs.getLong("valor_mensal_centavos"));m.put("descricao",rs.getString("descricao"));m.put("ativo",rs.getBoolean("ativo"));return m;}
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++){m.put(md.getColumnLabel(i),rs.getObject(i));}return m;}
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private String emptyToNull(String v){return v==null||v.isBlank()?null:v;}
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));}
private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);}
private Map<String,String> query(HttpExchange ex){Map<String,String> q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
private String required(Map<String,String> m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;}
private Long optionalLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);}
private long parseId(String s){return Long.parseLong(s);}
private boolean exists(Connection c,String sql,String value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
private boolean exists(Connection c,String sql,long value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
private void bind(PreparedStatement ps,List<Object> params)throws SQLException{for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));}
private List<String> stringArray(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return List.of();int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return List.of();String body=raw.substring(start+1,end).trim();if(body.isBlank())return List.of();List<String> out=new ArrayList<>();for(String part:body.split(",")){String v=part.trim();if(v.startsWith("\"")&&v.endsWith("\""))v=v.substring(1,v.length()-1);if(!v.isBlank())out.add(v);}return out;}
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return raw;return raw.substring(0,start)+"\"\""+raw.substring(end+1);}
}
@@ -0,0 +1,14 @@
package com.seconectai.condocontab.master.model;
import java.time.LocalDateTime;
public record Empresa(
Long id,
String nome,
String cnpj,
String slug,
String databaseName,
String status,
LocalDateTime criadoEm
) {
}
@@ -0,0 +1,9 @@
package com.seconectai.condocontab.master.model;
public record Funcionalidade(
Long id,
String chave,
String nome,
String descricao
) {
}
@@ -0,0 +1,16 @@
package com.seconectai.condocontab.master.model;
import java.time.LocalDateTime;
public record Funcionario(
Long id,
Long empresaId,
String nome,
String email,
String telefone,
String senhaHash,
String cargo,
String status,
LocalDateTime criadoEm
) {
}
@@ -0,0 +1,77 @@
package com.seconectai.condocontab.tenant;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.db.MigrationRunner;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class TenantDataSourceProvider {
private final AppConfig config;
private final DataSourceProvider masterDataSource;
private final Map<Long, Connection> conexoesPorEmpresa = new ConcurrentHashMap<>();
public TenantDataSourceProvider(AppConfig config, DataSourceProvider masterDataSource) {
this.config = config;
this.masterDataSource = masterDataSource;
}
public Connection getConnection(long empresaId) throws SQLException {
Connection existente = conexoesPorEmpresa.get(empresaId);
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
return existente;
}
synchronized (conexoesPorEmpresa) {
existente = conexoesPorEmpresa.get(empresaId);
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
return existente;
}
String databaseName = buscarDatabaseName(empresaId);
Connection nova = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword());
executarMigrationsTenant(nova, databaseName);
return nova;
}
}
public void invalidar(long empresaId) {
Connection conn = conexoesPorEmpresa.remove(empresaId);
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
System.err.println("Falha ao fechar conexao tenant da empresa " + empresaId + ": " + e.getMessage());
}
}
}
private void executarMigrationsTenant(Connection conn, String databaseName) throws SQLException {
try {
MigrationRunner.runMigrations(conn, "migrations/tenant");
} catch (IOException e) {
throw new SQLException("Falha ao carregar migrations tenant para " + databaseName, e);
}
}
private String buscarDatabaseName(long empresaId) throws SQLException {
try (Connection master = masterDataSource.getMasterConnection();
PreparedStatement ps = master.prepareStatement("SELECT database_name FROM empresas WHERE id = ?")) {
ps.setLong(1, empresaId);
try (var rs = ps.executeQuery()) {
if (!rs.next()) {
throw new SQLException("Empresa nao encontrada no master: " + empresaId);
}
return rs.getString("database_name");
}
}
}
// Ponto de atencao futuro: invalidar automaticamente quando empresa for bloqueada/cancelada.
}
@@ -0,0 +1,61 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.Condominio;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class CondominioDao {
public Condominio inserir(Connection conn, Condominio condominio) throws SQLException {
String sql = "INSERT INTO condominios (nome, cnpj, endereco) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, condominio.nome());
ps.setString(2, condominio.cnpj());
ps.setString(3, condominio.endereco());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para condominio");
}
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Condominio> buscarPorId(Connection conn, long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
public List<Condominio> listarTodos(Connection conn) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios ORDER BY nome")) {
try (ResultSet rs = ps.executeQuery()) {
List<Condominio> condominios = new ArrayList<>();
while (rs.next()) {
condominios.add(map(rs));
}
return condominios;
}
}
}
private Condominio map(ResultSet rs) throws SQLException {
return new Condominio(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("cnpj"),
rs.getString("endereco"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,90 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.ContatoCondominio;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class ContatoCondominioDao {
public void vincular(Connection conn, ContatoCondominio vinculo) throws SQLException {
String sql = """
INSERT INTO contato_condominio
(contato_id, condominio_id, tipo, data_inicio_mandato, data_fim_mandato, status)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (contato_id, condominio_id, tipo) DO UPDATE SET
data_inicio_mandato = EXCLUDED.data_inicio_mandato,
data_fim_mandato = EXCLUDED.data_fim_mandato,
status = EXCLUDED.status
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, vinculo.contatoId());
ps.setLong(2, vinculo.condominioId());
ps.setString(3, vinculo.tipo());
ps.setDate(4, vinculo.dataInicioMandato() == null ? null : Date.valueOf(vinculo.dataInicioMandato()));
ps.setDate(5, vinculo.dataFimMandato() == null ? null : Date.valueOf(vinculo.dataFimMandato()));
ps.setString(6, vinculo.status() == null ? "ativo" : vinculo.status());
ps.executeUpdate();
}
}
public List<ContatoCondominio> listarPorCondominio(Connection conn, long condominioId) throws SQLException {
String sql = """
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
FROM contato_condominio cc
JOIN contatos c ON c.id = cc.contato_id
WHERE cc.condominio_id = ?
ORDER BY cc.tipo, c.nome
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, condominioId);
try (ResultSet rs = ps.executeQuery()) {
List<ContatoCondominio> vinculos = new ArrayList<>();
while (rs.next()) {
vinculos.add(map(rs));
}
return vinculos;
}
}
}
public Optional<ContatoCondominio> buscarSindicoAtual(Connection conn, long condominioId) throws SQLException {
String sql = """
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
FROM contato_condominio cc
JOIN contatos c ON c.id = cc.contato_id
WHERE cc.condominio_id = ?
AND cc.tipo = 'sindico'
AND cc.status = 'ativo'
ORDER BY cc.data_inicio_mandato DESC NULLS LAST, c.nome
LIMIT 1
""";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, condominioId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private ContatoCondominio map(ResultSet rs) throws SQLException {
Date inicio = rs.getDate("data_inicio_mandato");
Date fim = rs.getDate("data_fim_mandato");
return new ContatoCondominio(
rs.getLong("contato_id"),
rs.getLong("condominio_id"),
rs.getString("tipo"),
inicio == null ? null : inicio.toLocalDate(),
fim == null ? null : fim.toLocalDate(),
rs.getString("status"),
rs.getString("contato_nome"),
rs.getString("contato_telefone"),
rs.getString("contato_email")
);
}
}
@@ -0,0 +1,47 @@
package com.seconectai.condocontab.tenant.dao;
import com.seconectai.condocontab.tenant.model.Contato;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Optional;
public final class ContatoDao {
public Contato inserir(Connection conn, Contato contato) throws SQLException {
String sql = "INSERT INTO contatos (nome, telefone, email) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, contato.nome());
ps.setString(2, contato.telefone());
ps.setString(3, contato.email());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) {
throw new SQLException("Falha ao obter id gerado para contato");
}
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
}
}
}
public Optional<Contato> buscarPorId(Connection conn, long id) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM contatos WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
}
}
}
private Contato map(ResultSet rs) throws SQLException {
return new Contato(
rs.getLong("id"),
rs.getString("nome"),
rs.getString("telefone"),
rs.getString("email"),
rs.getTimestamp("criado_em").toLocalDateTime()
);
}
}
@@ -0,0 +1,521 @@
package com.seconectai.condocontab.tenant.http;
import com.seconectai.condocontab.auth.http.AuthMiddleware;
import com.seconectai.condocontab.config.DataSourceProvider;
import com.seconectai.condocontab.http.HttpResponses;
import com.seconectai.condocontab.integracao.EmailClient;
import com.seconectai.condocontab.integracao.EvolutionApiClient;
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
import com.seconectai.condocontab.util.Json;
import com.seconectai.condocontab.util.SimpleJson;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
public final class ClienteHandlers implements HttpHandler {
private static final long MAX_UPLOAD = 10L * 1024L * 1024L;
private static final Set<String> EXT_OK = Set.of("pdf", "jpg", "jpeg", "png", "docx");
private final TenantDataSourceProvider tenantDs;
private final DataSourceProvider masterDs;
private final Path uploadRoot;
private final EvolutionApiClient evolutionApiClient;
private final EmailClient emailClient;
public ClienteHandlers(TenantDataSourceProvider tenantDs, DataSourceProvider masterDs, Path uploadRoot, EvolutionApiClient evolutionApiClient, EmailClient emailClient) {
this.tenantDs = tenantDs;
this.masterDs = masterDs;
this.uploadRoot = uploadRoot;
this.evolutionApiClient = evolutionApiClient;
this.emailClient = emailClient;
}
@Override public void handle(HttpExchange ex) throws IOException {
try { route(ex); }
catch (IllegalArgumentException e) { HttpResponses.json(ex,400,Map.of("erro","requisicao_invalida","mensagem",e.getMessage())); }
catch (SQLException e) { HttpResponses.json(ex,500,Map.of("erro","erro_banco","mensagem",e.getMessage())); }
catch (Throwable e) { e.printStackTrace(System.err); HttpResponses.json(ex,500,Map.of("erro","erro_interno","mensagem",String.valueOf(e.getMessage()))); }
}
private void route(HttpExchange ex) throws Exception {
String method=ex.getRequestMethod(); String path=ex.getRequestURI().getPath();
String[] p=Arrays.stream(path.substring("/api".length()).split("/")).filter(s->!s.isBlank()).map(s->URLDecoder.decode(s,StandardCharsets.UTF_8)).toArray(String[]::new);
long empresaId=(long)ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID); long funcionarioId=(long)ex.getAttribute(AuthMiddleware.ATTR_FUNCIONARIO_ID); String cargo=String.valueOf(ex.getAttribute(AuthMiddleware.ATTR_CARGO));
try(Connection c=tenantDs.getConnection(empresaId)){
if(p.length==1&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} if("POST".equals(method)) criarFuncionarioEmpresa(ex,empresaId); else if("GET".equals(method)) listarFuncionariosEmpresa(ex,empresaId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} long func=id(p[1]); if("PUT".equals(method)) atualizarFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else if("DELETE".equals(method)) excluirFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"lembrete-pessoas".equals(p[0])&&"GET".equals(method)){ listarPessoasLembrete(ex,empresaId); return; }
if(p.length==1&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} if("POST".equals(method)) criarExtra(ex,c,funcionarioId); else if("GET".equals(method)) listarExtras(ex,c); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} long extra=id(p[1]); if("PUT".equals(method)) atualizarExtra(ex,c,extra,funcionarioId); else if("DELETE".equals(method)) excluirExtra(ex,c,extra,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"lembretes".equals(p[0])){ if("POST".equals(method)) criarLembrete(ex,c,empresaId,funcionarioId); else if("GET".equals(method)) listarLembretes(ex,c,empresaId,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"lembretes".equals(p[0])&&"enviar".equals(p[2])&&"POST".equals(method)){ enviarLembreteManual(ex,c,empresaId,id(p[1]),cargo,funcionarioId); return; }
if(p.length==2&&"lembretes".equals(p[0])){ long lembrete=id(p[1]); if("PUT".equals(method)) atualizarLembrete(ex,c,empresaId,lembrete,cargo,funcionarioId); else if("DELETE".equals(method)) excluirLembrete(ex,c,lembrete,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode configurar o WhatsApp."));return;} }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"conectar".equals(p[2])&&"POST".equals(method)){ conectarEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"status".equals(p[2])&&"GET".equals(method)){ statusEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"qrcode".equals(p[2])&&"POST".equals(method)){ qrcodeEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"desconectar".equals(p[2])&&"POST".equals(method)){ desconectarEvolution(ex,empresaId); return; }
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"reconectar".equals(p[2])&&"POST".equals(method)){ reconectarEvolution(ex,empresaId); return; }
if(p.length==1&&"assembleias-calendario".equals(p[0])&&"GET".equals(method)){ listarAssembleiasCalendario(ex,c,empresaId,cargo,funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAviso(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarAvisos(ex,c,cond); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheAviso(ex,c,cond,id(p[3])); else if("DELETE".equals(method)) excluirAviso(ex,c,empresaId,cond,id(p[3]),funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==5&&"condominios".equals(p[0])&&"avisos".equals(p[2])&&"reenviar".equals(p[4])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} reenviarAviso(ex,c,empresaId,cond,id(p[3])); return; }
if(p.length==1&&"condominios".equals(p[0])){ if("POST".equals(method)) criarCondominio(ex,c,funcionarioId,cargo); else if("GET".equals(method)) listarCondominios(ex,c,funcionarioId,cargo); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==2&&"condominios".equals(p[0])){ long id=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,id)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheCondominio(ex,c,id); else if("PUT".equals(method)) atualizarCondominio(ex,c,id,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==1&&"contatos".equals(p[0])&&"POST".equals(method)){ criarContato(ex,c,funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"GET".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarContatos(ex,c,cond); return; }
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"vincular".equals(p[3])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} vincularContato(ex,c,cond,funcionarioId); return; }
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"DELETE".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} desvincularContato(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])){ long cond=id(p[1]); if("POST".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode vincular funcionarios a condominios."));return;} vincularFuncionario(ex,c,empresaId,cond,funcionarioId); } else if("GET".equals(method)){ if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarFuncionariosVinculados(ex,c,empresaId,cond); } else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])&&"DELETE".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode desvincular funcionarios de condominios."));return;} execWrite(ex,c,"DELETE FROM funcionario_condominio WHERE condominio_id=? AND funcionario_id=?",Arrays.asList(id(p[1]),id(p[3])),funcionarioId,"funcionario_condominio",id(p[3])); return; }
if(p.length==3&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarEspaco(ex,c,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM espacos_comuns WHERE condominio_id=? ORDER BY nome",cond,"espacos"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarEspaco(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"assembleias".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAssembleia(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM assembleias WHERE condominio_id=? ORDER BY data_hora DESC",cond,"assembleias"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==4&&"condominios".equals(p[0])&&"assembleias".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarAssembleia(ex,c,cond,id(p[3]),funcionarioId); return; }
if(p.length==3&&"condominios".equals(p[0])&&"documentos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) uploadDocumento(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM documentos WHERE condominio_id=? ORDER BY criado_em DESC",cond,"documentos"); else HttpResponses.methodNotAllowed(ex); return; }
if(p.length==3&&"documentos".equals(p[0])&&"download".equals(p[2])&&"GET".equals(method)){ downloadDocumento(ex,c,id(p[1])); return; }
HttpResponses.json(ex,404,Map.of("erro","endpoint_nao_encontrado"));
}
}
private void criarCondominio(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{Map<String,String>b=body(ex);String cnpj=cnpj(req(b,"cnpj"));if(one(c,"SELECT id FROM condominios WHERE regexp_replace(cnpj,'\\D','','g')=?",Arrays.asList(cnpj))!=null){HttpResponses.json(ex,400,Map.of("erro","cnpj_duplicado","mensagem","Ja existe um condominio cadastrado com este CNPJ."));return;}c.setAutoCommit(false);try{long id=insert(c,"INSERT INTO condominios (nome,cnpj,endereco) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),cnpj,nullable(b.get("endereco"))));if(!adminEmpresa(cargo)){try(PreparedStatement ps=c.prepareStatement("INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,fid);ps.setLong(2,id);ps.executeUpdate();}}audit(c,fid,"create","condominios",id,null,b);c.commit();detalheCondominio(ex,c,id);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void listarCondominios(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{if(adminEmpresa(cargo)){listar(ex,c,"SELECT * FROM condominios ORDER BY nome","condominios");return;}HttpResponses.json(ex,200,Map.of("condominios",rows(c,"SELECT c.* FROM condominios c JOIN funcionario_condominio fc ON fc.condominio_id=c.id WHERE fc.funcionario_id=? ORDER BY c.nome",Arrays.asList(fid))));}
private boolean podeAcessarCondominio(Connection c,String cargo,long fid,long cond)throws SQLException{return adminEmpresa(cargo)||count(c,"SELECT count(*) FROM funcionario_condominio WHERE funcionario_id=? AND condominio_id=?",fid,cond)>0;}
private void detalheCondominio(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object> cond=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(cond==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} Map<String,Object> sind=one(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.tipo='sindico' AND cc.status='ativo' ORDER BY cc.data_inicio_mandato DESC NULLS LAST LIMIT 1",Arrays.asList(id)); long ass=count(c,"SELECT count(*) FROM assembleias WHERE condominio_id=? AND data_hora>now() AND status='agendada'",id); long av=count(c,"SELECT count(*) FROM avisos WHERE condominio_id=? AND (data_expiracao IS NULL OR data_expiracao>now())",id); Map<String,Object> resp=new LinkedHashMap<>();resp.put("condominio",cond);resp.put("sindico_atual",sind);resp.put("assembleias_futuras",ass);resp.put("avisos_ativos",av);HttpResponses.json(ex,200,resp);}
private void atualizarCondominio(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE condominios SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj), endereco=COALESCE(?,endereco) WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("cnpj"));ps.setString(3,b.get("endereco"));ps.setLong(4,id);ps.executeUpdate();} audit(c,fid,"update","condominios",id,old,b); detalheCondominio(ex,c,id);}
private void criarContato(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email")))); audit(c,fid,"create","contatos",id,null,b); HttpResponses.json(ex,201,Map.of("contato",one(c,"SELECT * FROM contatos WHERE id=?",Arrays.asList(id))));}
private void vincularContato(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); Long contato=optLong(b.get("contato_id")); if(contato==null){contato=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email"))));} String tipo=req(b,"tipo"); if("sindico".equals(tipo)){try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND tipo='sindico' AND status='ativo' AND contato_id<>?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.executeUpdate();}} try(PreparedStatement ps=c.prepareStatement("INSERT INTO contato_condominio (contato_id,condominio_id,tipo,data_inicio_mandato,data_fim_mandato,status) VALUES (?,?,?,?,?,'ativo') ON CONFLICT (contato_id,condominio_id,tipo) DO UPDATE SET data_inicio_mandato=EXCLUDED.data_inicio_mandato,data_fim_mandato=EXCLUDED.data_fim_mandato,status='ativo'")){ps.setLong(1,contato);ps.setLong(2,cond);ps.setString(3,tipo);setDate(ps,4,b.get("data_inicio_mandato"));setDate(ps,5,b.get("data_fim_mandato"));ps.executeUpdate();} if(Set.of("sindico","subsindico","conselheiro").contains(tipo))vincularContatoAosAvisosRecorrentes(c,cond,contato); audit(c,fid,"create","contato_condominio",contato,null,b); listarContatos(ex,c,cond);}
private void vincularContatoAosAvisosRecorrentes(Connection c,long cond,long contato)throws SQLException{
List<Map<String,Object>> avisos=rows(c,"SELECT id FROM avisos WHERE condominio_id=? AND recorrencia_ativa=true AND recorrencia_meses>0 AND (enviar_whatsapp=true OR enviar_email=true)",Arrays.asList(cond));
for(Map<String,Object>a:avisos){
long aviso=((Number)a.get("id")).longValue();
try(PreparedStatement ps=c.prepareStatement("INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,aviso);ps.setString(2,"contato");ps.setLong(3,contato);ps.executeUpdate();}
enfileirarAviso(c,aviso,Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
}
}
private void listarContatos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("contatos",rows(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.status='ativo' ORDER BY cc.tipo,c.nome",Arrays.asList(cond))));}
private void desvincularContato(HttpExchange ex,Connection c,long cond,long contato,long fid)throws Exception{String tipo=query(ex).get("tipo"); try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND contato_id=? AND tipo=?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.setString(3,tipo);ps.executeUpdate();} audit(c,fid,"delete","contato_condominio",contato,null,Map.of("tipo",tipo)); HttpResponses.json(ex,200,Map.of("ok",true));}
private void vincularFuncionario(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long func=Long.parseLong(req(b,"funcionario_id")); try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT 1 FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(!rs.next()){HttpResponses.json(ex,400,Map.of("erro","funcionario_invalido"));return;}}} execWrite(ex,c,"INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(func,cond),fid,"funcionario_condominio",func);}
private void listarFuncionariosVinculados(HttpExchange ex,Connection c,long emp,long cond)throws Exception{List<Map<String,Object>> links=rows(c,"SELECT * FROM funcionario_condominio WHERE condominio_id=? ORDER BY funcionario_id",Arrays.asList(cond));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> link:links){long func=((Number)link.get("funcionario_id")).longValue();ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next()){Map<String,Object> item=resultMap(rs);item.put("funcionario_id",func);item.put("condominio_id",cond);out.add(item);}}}}HttpResponses.json(ex,200,Map.of("funcionarios",out));}
private void criarEspaco(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO espacos_comuns (condominio_id,nome,descricao,valor_utilizacao_centavos,indice_correcao) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"nome"),nullable(b.get("descricao")),optLong(b.get("valor_utilizacao_centavos")),nullable(b.get("indice_correcao")))); audit(c,fid,"create","espacos_comuns",id,null,b); HttpResponses.json(ex,201,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(id))));}
private void atualizarEspaco(HttpExchange ex,Connection c,long cond,long eid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM espacos_comuns WHERE id=? AND condominio_id=?",Arrays.asList(eid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","espaco_nao_encontrado"));return;} Long novo=optLong(b.get("valor_utilizacao_centavos")); try(PreparedStatement ps=c.prepareStatement("UPDATE espacos_comuns SET nome=COALESCE(?,nome), descricao=COALESCE(?,descricao), valor_utilizacao_centavos=COALESCE(?,valor_utilizacao_centavos), indice_correcao=COALESCE(?,indice_correcao), data_ultimo_reajuste=CASE WHEN ? IS NOT NULL AND ?<>valor_utilizacao_centavos THEN current_date ELSE data_ultimo_reajuste END WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("descricao")); if(novo==null)ps.setNull(3,Types.BIGINT);else ps.setLong(3,novo); ps.setString(4,b.get("indice_correcao")); if(novo==null){ps.setNull(5,Types.BIGINT);ps.setNull(6,Types.BIGINT);}else{ps.setLong(5,novo);ps.setLong(6,novo);} ps.setLong(7,eid);ps.setLong(8,cond);ps.executeUpdate();} audit(c,fid,"update","espacos_comuns",eid,old,b); HttpResponses.json(ex,200,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(eid))));}
private void criarAssembleia(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{
Map<String,String>b=body(ex);
long id=insert(c,"INSERT INTO assembleias (condominio_id,tipo,data_hora,local,pauta) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"tipo"),ts(req(b,"data_hora")),nullable(b.get("local")),nullable(b.get("pauta"))));
audit(c,fid,"create","assembleias",id,null,b);
Map<String,Object> envio=criarAvisoAssembleia(c,emp,cond,id,fid,b);
HttpResponses.json(ex,201,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(id)),"envio",envio));
}
private Map<String,Object> criarAvisoAssembleia(Connection c,long emp,long cond,long assembleia,long fid,Map<String,String>b)throws Exception{
List<Map<String,Object>> contatosAviso=rows(c,"SELECT contato_id FROM contato_condominio WHERE condominio_id=? AND status=?",Arrays.asList(cond,"ativo"));
List<Map<String,Object>> funcionariosAviso=rows(c,"SELECT funcionario_id FROM funcionario_condominio WHERE condominio_id=?",Arrays.asList(cond));
int destinatarios=contatosAviso.size()+funcionariosAviso.size();
if(destinatarios==0)return Map.of("enviados",0,"falhas",0,"mensagem","Nenhum destinatario vinculado ao condominio.");
if(!funcionalidadeAtiva(emp,"whatsapp_envio"))return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao liberado para esta empresa.");
if(evolutionAprovada(emp)==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
String titulo="Assembleia " + req(b,"tipo");
StringBuilder mensagem=new StringBuilder();
mensagem.append("Nova assembleia agendada.");
mensagem.append("\nData/hora: ").append(req(b,"data_hora").replace('T',' '));
if(b.get("local")!=null&&!b.get("local").isBlank())mensagem.append("\nLocal: ").append(b.get("local"));
if(b.get("pauta")!=null&&!b.get("pauta").isBlank())mensagem.append("\nPauta: ").append(b.get("pauta"));
long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario) VALUES (?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,assembleia,"assembleia",titulo,mensagem.toString(),ts(b.get("data_hora")),"normal",true,false,"nao_enviado",fid));
for(Map<String,Object> row:contatosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"contato",((Number)row.get("contato_id")).longValue()));
for(Map<String,Object> row:funcionariosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"funcionario",((Number)row.get("funcionario_id")).longValue()));
enfileirarAviso(c,aviso,ts(b.get("data_hora")));
return Map.of("aviso_id",aviso,"destinatarios",destinatarios,"status","enfileirado");
}
private void atualizarAssembleia(HttpExchange ex,Connection c,long cond,long aid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM assembleias WHERE id=? AND condominio_id=?",Arrays.asList(aid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","assembleia_nao_encontrada"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE assembleias SET tipo=COALESCE(?,tipo), data_hora=COALESCE(?,data_hora), local=COALESCE(?,local), pauta=COALESCE(?,pauta), status=COALESCE(?,status) WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("tipo")); if(b.get("data_hora")==null)ps.setNull(2,Types.TIMESTAMP);else ps.setTimestamp(2,ts(b.get("data_hora"))); ps.setString(3,b.get("local"));ps.setString(4,b.get("pauta"));ps.setString(5,b.get("status"));ps.setLong(6,aid);ps.setLong(7,cond);ps.executeUpdate();} audit(c,fid,"update","assembleias",aid,old,b); HttpResponses.json(ex,200,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(aid))));}
private void uploadDocumento(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Multipart mp=parseMultipart(ex); if(mp.bytes.length>MAX_UPLOAD){HttpResponses.json(ex,400,Map.of("erro","arquivo_muito_grande"));return;} String ext=ext(mp.filename); if(!EXT_OK.contains(ext)){HttpResponses.json(ex,400,Map.of("erro","extensao_nao_permitida"));return;} String db=databaseName(emp); Path dir=uploadRoot.resolve(db).resolve(String.valueOf(cond)); Files.createDirectories(dir); String safe=UUID.randomUUID()+"-"+mp.filename.replaceAll("[^a-zA-Z0-9._-]","_"); Path file=dir.resolve(safe); Files.write(file,mp.bytes); String rel=db+"/"+cond+"/"+safe; long id=insert(c,"INSERT INTO documentos (condominio_id,tipo,nome_arquivo,caminho_armazenamento,enviado_por) VALUES (?,?,?,?,?)",Arrays.asList(cond,nullable(mp.tipo),mp.filename,rel,fid)); audit(c,fid,"create","documentos",id,null,Map.of("nome_arquivo",mp.filename)); HttpResponses.json(ex,201,Map.of("documento",one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id))));}
private void downloadDocumento(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object>d=one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id)); if(d==null){HttpResponses.json(ex,404,Map.of("erro","documento_nao_encontrado"));return;} Path f=uploadRoot.resolve(String.valueOf(d.get("caminho_armazenamento"))); if(!Files.exists(f)){HttpResponses.json(ex,404,Map.of("erro","arquivo_nao_encontrado"));return;} ex.getResponseHeaders().set("Content-Disposition","attachment; filename=\""+d.get("nome_arquivo")+"\""); ex.sendResponseHeaders(200,Files.size(f)); Files.copy(f,ex.getResponseBody()); ex.close();}
private void listarExtras(HttpExchange ex,Connection c)throws Exception{HttpResponses.json(ex,200,Map.of("extras",rows(c,"SELECT * FROM destinatarios_extra WHERE status<>'excluido' ORDER BY nome",Arrays.asList())));}
private void criarExtra(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex);long id=insert(c,"INSERT INTO destinatarios_extra (nome,email,telefone,status) VALUES (?,?,?,'ativo')",Arrays.asList(req(b,"nome"),nullable(b.get("email")),nullable(b.get("telefone"))));audit(c,fid,"create","destinatarios_extra",id,null,b);HttpResponses.json(ex,201,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
private void atualizarExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}Map<String,String>b=body(ex);try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("email"));ps.setString(3,b.get("telefone"));ps.setString(4,b.get("status"));ps.setLong(5,id);ps.executeUpdate();}audit(c,fid,"update","destinatarios_extra",id,old,b);HttpResponses.json(ex,200,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
private void excluirExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET status='excluido', atualizado_em=now() WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","destinatarios_extra",id,old,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
private void listarPessoasLembrete(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE empresa_id=? AND status='ativo' ORDER BY nome")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("pessoas",out));}}
private void criarLembrete(HttpExchange ex,Connection c,long emp,long fid)throws Exception{
String raw=rawBody(ex);
Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));
List<Long>dests=longArray(raw,"destinatarios");
Timestamp dataHora=ts(b.get("data_hora"));
boolean enviarAgora=dataHora==null||!dataHora.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
if(enviarAgora&&!dests.isEmpty()&&evolutionAprovada(emp)==null){
HttpResponses.json(ex,400,Map.of("erro","instancia_whatsapp_indisponivel","mensagem","Configure, conecte e aprove o WhatsApp antes de enviar lembretes."));
return;
}
long id;
c.setAutoCommit(false);
try{
id=insert(c,"INSERT INTO lembretes (titulo,mensagem,data_hora,status,criado_por_funcionario) VALUES (?,?,?,?,?)",Arrays.asList(req(b,"titulo"),req(b,"mensagem"),dataHora,b.getOrDefault("status","ativo"),fid));
salvarDestinatariosLembrete(c,id,dests);
if(!dests.isEmpty()&&"ativo".equalsIgnoreCase(b.getOrDefault("status","ativo")))enfileirarLembrete(c,id,dataHora);
audit(c,fid,"create","lembretes",id,null,b);
c.commit();
}catch(Exception err){
c.rollback();
throw err;
}finally{
c.setAutoCommit(true);
}
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
HttpResponses.json(ex,201,Map.of("lembrete",lembrete,"envio",Map.of("enfileirado",!dests.isEmpty(),"agendado",!enviarAgora)));
}
private void listarLembretes(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{List<Map<String,Object>> base=rows(c,"SELECT l.*, CASE WHEN l.criado_por_funcionario=? THEN true ELSE false END AS criado_por_mim FROM lembretes l ORDER BY COALESCE(l.data_hora,l.criado_em) DESC,l.id DESC",Arrays.asList(fid));for(Map<String,Object> l:base){long id=((Number)l.get("id")).longValue();boolean podeAlterar=adminEmpresa(cargo)||Boolean.TRUE.equals(l.get("criado_por_mim"));String status=str(l.get("status"));l.put("pode_alterar",podeAlterar);l.put("pode_editar",podeAlterar&&!"concluido".equalsIgnoreCase(status)&&!"enviado".equalsIgnoreCase(status));l.put("destinatarios",destinatariosLembrete(c,emp,id));}HttpResponses.json(ex,200,Map.of("lembretes",base));}
private void enviarLembreteManual(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
if(lembrete==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}
if(!podeAlterarLembrete(lembrete,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode enviar."));return;}
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
if(dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o lembrete."));return;}
Map<String,Object> envio=enviarLembreteWhatsApp(emp,id,lembrete);
if(Number.class.isInstance(envio.get("falhas"))&&((Number)envio.get("falhas")).intValue()==0&&Number.class.isInstance(envio.get("enviados"))&&((Number)envio.get("enviados")).intValue()>0){finalizarOuReagendarLembrete(c,id);}
HttpResponses.json(ex,200,Map.of("envio",envio));
}
private void atualizarLembrete(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode alterar."));return;}String statusAtual=str(old.get("status"));if("concluido".equalsIgnoreCase(statusAtual)||"enviado".equalsIgnoreCase(statusAtual)){HttpResponses.json(ex,409,Map.of("erro","lembrete_ja_enviado","mensagem","Este lembrete ja foi enviado e nao pode mais ser editado."));return;}String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));List<Long>dests=longArray(raw,"destinatarios");c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET titulo=COALESCE(?,titulo), mensagem=COALESCE(?,mensagem), data_hora=COALESCE(?,data_hora), status=COALESCE(?,status), recorrencia_meses=?, recorrencia_ativa=?, atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("titulo"));ps.setString(2,b.get("mensagem"));if(b.containsKey("data_hora")){Timestamp t=ts(b.get("data_hora"));if(t==null)ps.setNull(3,Types.TIMESTAMP);else ps.setTimestamp(3,t);}else ps.setNull(3,Types.TIMESTAMP);ps.setString(4,b.get("status"));int rec=recorrenciaMeses(b.get("recorrencia_meses"));ps.setInt(5,rec);ps.setBoolean(6,rec>0);ps.setLong(7,id);ps.executeUpdate();}if(raw.contains("\"destinatarios\""))salvarDestinatariosLembrete(c,id,dests);Map<String,Object> atual=one(c,"SELECT status,data_hora FROM lembretes WHERE id=?",Arrays.asList(id));if(atual!=null&&"ativo".equalsIgnoreCase(str(atual.get("status"))))enfileirarLembrete(c,id,(Timestamp)atual.get("data_hora"));audit(c,fid,"update","lembretes",id,old,b);c.commit();HttpResponses.json(ex,200,Map.of("lembrete",lembreteComDetalhes(c,emp,id)));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void excluirLembrete(HttpExchange ex,Connection c,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode excluir."));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembretes WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","lembretes",id,old,Map.of("ok",true));c.commit();HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private boolean podeAlterarLembrete(Map<String,Object> lembrete,String cargo,long fid){return adminEmpresa(cargo)||((Number)lembrete.get("criado_por_funcionario")).longValue()==fid;}
private Map<String,Object> enviarLembreteWhatsApp(long emp,long lembreteId,Map<String,Object> lembrete)throws Exception{
Map<String,Object> inst=evolutionAprovada(emp);
if(inst==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);
String texto="Lembrete: "+str(lembrete.get("titulo"))+"\n\n"+str(lembrete.get("mensagem"));
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
int enviados=0, falhas=0;
List<String> detalhes=new ArrayList<>();
for(Map<String,Object> dest:dests){
long did=((Number)dest.get("id")).longValue();
String nome=str(dest.get("nome"));
String tel=telefoneWhatsapp(dest.get("telefone"));
try{
if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");
String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,texto);
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","enviado",limite(resposta));
enviados++;
}catch(Exception err){
falhas++;
detalhes.add(nome+": "+err.getMessage());
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","falhou",limite(err.getMessage()));
}
}
Map<String,Object> out=new LinkedHashMap<>();
out.put("enviados",enviados);
out.put("falhas",falhas);
if(!detalhes.isEmpty())out.put("detalhes",detalhes);
return out;
}
private void salvarDestinatariosLembrete(Connection c,long lembrete,List<Long>dests)throws SQLException{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembrete_destinatarios WHERE lembrete_id=?")){ps.setLong(1,lembrete);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO lembrete_destinatarios (lembrete_id,funcionario_id) VALUES (?,?) ON CONFLICT DO NOTHING")){for(Long d:dests){ps.setLong(1,lembrete);ps.setLong(2,d);ps.addBatch();}ps.executeBatch();}}
private Map<String,Object> lembreteComDetalhes(Connection c,long emp,long id)throws Exception{Map<String,Object> l=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(l!=null&&emp>0)l.put("destinatarios",destinatariosLembrete(c,emp,id));return l;}
private List<Map<String,Object>> destinatariosLembrete(Connection c,long emp,long id)throws Exception{List<Map<String,Object>> ids=rows(c,"SELECT funcionario_id FROM lembrete_destinatarios WHERE lembrete_id=? ORDER BY funcionario_id",Arrays.asList(id));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> row:ids){ps.setLong(1,((Number)row.get("funcionario_id")).longValue());ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next())out.add(resultMap(rs));}}}return out;}
private void listarFuncionariosEmpresa(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE empresa_id=? ORDER BY id")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("funcionarios",out));}}
private void criarFuncionarioEmpresa(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);try(Connection m=masterDs.getMasterConnection()){if(one(m,"SELECT id FROM funcionarios WHERE email=?",Arrays.asList(req(b,"email")))!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}try(PreparedStatement ps=m.prepareStatement("INSERT INTO funcionarios (empresa_id,nome,email,telefone,senha_hash,cargo,status) VALUES (?,?,?,?,?,?,'ativo') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,req(b,"nome"));ps.setString(3,req(b,"email"));ps.setString(4,b.get("telefone"));ps.setString(5,com.seconectai.condocontab.auth.PasswordHasher.hash(req(b,"senha")));ps.setString(6,b.getOrDefault("cargo","funcionario"));try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"ok",true));}}}}
private void atualizarFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
Map<String,String>b=body(ex);
try(Connection m=masterDs.getMasterConnection()){
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
if("admin_empresa".equals(str(atual.get("cargo")))&&funcId!=solicitante){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser alterada por aqui."));return;}
String email=b.get("email");
if(email!=null&&!email.isBlank()){
Map<String,Object> dono=one(m,"SELECT id FROM funcionarios WHERE email=? AND id<>?",Arrays.asList(email,funcId));
if(dono!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}
}
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), senha_hash=COALESCE(?,senha_hash) WHERE id=? AND empresa_id=?")){
ps.setString(1,b.get("nome"));
ps.setString(2,email);
ps.setString(3,b.get("telefone"));
ps.setString(4,b.get("status"));
String senha=b.get("senha");
ps.setString(5,senha==null||senha.isBlank()?null:com.seconectai.condocontab.auth.PasswordHasher.hash(senha));
ps.setLong(6,funcId);
ps.setLong(7,empresaId);
ps.executeUpdate();
}
HttpResponses.json(ex,200,Map.of("funcionario",one(m,"SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE id=?",Arrays.asList(funcId))));
}
}
private void excluirFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
if(funcId==solicitante){HttpResponses.json(ex,400,Map.of("erro","auto_exclusao_bloqueada","mensagem","Voce nao pode excluir seu proprio acesso."));return;}
try(Connection m=masterDs.getMasterConnection()){
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
if("admin_empresa".equals(str(atual.get("cargo")))){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser excluida."));return;}
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET status='inativo' WHERE id=? AND empresa_id=?")){ps.setLong(1,funcId);ps.setLong(2,empresaId);ps.executeUpdate();}
}
try(Connection c=tenantDs.getConnection(empresaId);PreparedStatement ps=c.prepareStatement("DELETE FROM funcionario_condominio WHERE funcionario_id=?")){
ps.setLong(1,funcId);
ps.executeUpdate();
}
HttpResponses.json(ex,200,Map.of("ok",true));
}
private boolean adminEmpresa(String cargo){return "admin_empresa".equals(cargo);}
private void conectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);String url=b.getOrDefault("instance_url","");String numero=EvolutionApiClient.onlyDigits(req(b,"numero_whatsapp"));if(numero.length()<10){HttpResponses.json(ex,400,Map.of("erro","numero_whatsapp_invalido","mensagem","Informe o numero com DDD e codigo do pais."));return;}try(Connection m=masterDs.getMasterConnection()){liberarCadastrosEvolutionOrfaos(m,numero);Map<String,Object> existente=one(m,"SELECT id,empresa_id,status FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id DESC LIMIT 1",Arrays.asList(numero));if(existente!=null){HttpResponses.json(ex,409,Map.of("erro","numero_whatsapp_ja_cadastrado","mensagem","Este numero de WhatsApp ja esta cadastrado em uma conexao."));return;}}String instance="empresa"+empresaId+"_"+System.currentTimeMillis();String token=UUID.randomUUID().toString().replace("-","");var criada=evolutionApiClient.criarInstancia(url,instance,token,numero);try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO evolution_instancias (empresa_id,instance_url,instance_token_enc,numero_whatsapp,status) VALUES (?,?,?,?, 'pendente') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,url);ps.setString(3,criada.instanceName()+":"+criada.token());ps.setString(4,numero);try(ResultSet rs=ps.executeQuery()){rs.next();Map<String,Object> resp=new LinkedHashMap<>();resp.put("id",rs.getLong(1));resp.put("instance_name",criada.instanceName());resp.put("status","pendente");resp.put("qrcode_base64",criada.qrcodeBase64());resp.put("mock",criada.mock());HttpResponses.json(ex,201,resp);}}}
private void liberarCadastrosEvolutionOrfaos(Connection m,String numero)throws Exception{List<Map<String,Object>> existentes=rows(m,"SELECT id,instance_url,instance_token_enc FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id",Arrays.asList(numero));for(Map<String,Object> inst:existentes){String token=str(inst.get("instance_token_enc"));String instanceName=token.split(":",2)[0];if(instanceName.isBlank())continue;try{evolutionApiClient.estado(str(inst.get("instance_url")),instanceName);}catch(Exception err){if(!evolutionNaoEncontrada(err))throw err;try(PreparedStatement ps=m.prepareStatement("DELETE FROM evolution_instancias WHERE id=?")){ps.setLong(1,((Number)inst.get("id")).longValue());ps.executeUpdate();}}}}
private boolean evolutionNaoEncontrada(Exception err){String msg=err.getMessage()==null?"":err.getMessage().toLowerCase(Locale.ROOT);return msg.contains("404")||msg.contains("not found")||msg.contains("instance not found")||msg.contains("nao encontrada")||msg.contains("não encontrada");}
private void statusEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,200,Map.of("status","nao_configurada"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("instancia",inst);try{String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);resp.put("evolution",evolutionApiClient.estado(str(inst.get("instance_url")),parts[0]));}catch(Exception ignored){}HttpResponses.json(ex,200,resp);}
private void qrcodeEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);Map<String,Object> qr=evolutionApiClient.conectar(str(inst.get("instance_url")),parts[0],str(inst.get("numero_whatsapp")));HttpResponses.json(ex,200,qr);}
private void desconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.logout(str(inst.get("instance_url")),parts[0]));}
private void reconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.reiniciar(str(inst.get("instance_url")),parts[0]));}
private Map<String,Object> evolutionInstancia(long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC,id DESC LIMIT 1")){ps.setLong(1,empresaId);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private void listarAssembleiasCalendario(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{
String sql=adminEmpresa(cargo)?"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id ORDER BY a.data_hora DESC":"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id JOIN funcionario_condominio fc ON fc.condominio_id=a.condominio_id AND fc.funcionario_id=? ORDER BY a.data_hora DESC";
List<Map<String,Object>> out=rows(c,sql,adminEmpresa(cargo)?Arrays.asList():Arrays.asList(fid));
for(Map<String,Object>a:out){
long aid=((Number)a.get("id")).longValue();
Map<String,Object> aviso=one(c,"SELECT id FROM avisos WHERE assembleia_id=? ORDER BY id DESC LIMIT 1",Arrays.asList(aid));
if(aviso!=null){long avisoId=((Number)aviso.get("id")).longValue();a.put("aviso_id",avisoId);a.put("destinatarios",destinatariosAviso(c,emp,avisoId));}
else a.put("destinatarios",List.of());
}
HttpResponses.json(ex,200,Map.of("assembleias",out));
}
private List<Map<String,Object>> destinatariosAviso(Connection c,long emp,long aviso)throws Exception{
List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso));
List<Map<String,Object>> out=new ArrayList<>();
for(Map<String,Object>d:dests){
String tipo=str(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> pessoa;
if("funcionario".equals(tipo))pessoa=funcionario(emp,did);else if("extra".equals(tipo))pessoa=one(c,"SELECT id,nome,email,telefone FROM destinatarios_extra WHERE id=?",Arrays.asList(did));else pessoa=one(c,"SELECT id,nome,email,telefone FROM contatos WHERE id=?",Arrays.asList(did));
if(pessoa!=null){Map<String,Object> item=new LinkedHashMap<>(pessoa);item.put("tipo",tipo);out.add(item);}
}
return out;
}
private void criarAviso(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(removeArrayField(raw,"documentos"),"destinatarios"));boolean w=bool(b.get("enviar_whatsapp"));boolean e=bool(b.get("enviar_email"));List<Destinatario> dests=destinatarios(raw);if((w||e)&&dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o aviso."));return;}Timestamp dataEnvio=ts(b.get("data_evento"));boolean agendado=dataEnvio!=null&&dataEnvio.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));if(w&&!funcionalidadeAtiva(emp,"whatsapp_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_whatsapp_inativa","mensagem","Envio por WhatsApp nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque WhatsApp."));return;}if(e&&!funcionalidadeAtiva(emp,"email_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_email_inativa","mensagem","Envio por e-mail nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque E-mail."));return;}if(w&&evolutionAprovada(emp)==null){HttpResponses.json(ex,400,Map.of("erro","instancia_pendente_aprovacao","mensagem","A conexao WhatsApp ainda nao foi aprovada no painel Master."));return;}c.setAutoCommit(false);try{int rec=recorrenciaMeses(b.get("recorrencia_meses"));long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,data_expiracao,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario,recorrencia_meses,recorrencia_ativa) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,optLong(b.get("assembleia_id")),b.getOrDefault("tipo","aviso"),req(b,"titulo"),req(b,"mensagem"),dataEnvio,ts(b.get("data_expiracao")),b.getOrDefault("prioridade","normal"),w,e,agendado?"agendado":"nao_enviado",fid,rec,rec>0));for(Long d:longArray(raw,"documentos")){insert(c,"INSERT INTO aviso_documentos (aviso_id,documento_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d));}for(Destinatario d:dests){insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d.tipo(),d.id()));}audit(c,fid,"create","avisos",aviso,null,b);if(w||e)enfileirarAviso(c,aviso,dataEnvio);c.commit();detalheAviso(ex,c,cond,aviso);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void listarAvisos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("avisos",rows(c,"SELECT * FROM avisos WHERE condominio_id=? ORDER BY data_publicacao DESC,id DESC",Arrays.asList(cond))));}
private void detalheAviso(HttpExchange ex,Connection c,long cond,long aviso)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("aviso",a);resp.put("documentos",rows(c,"SELECT ad.*, d.nome_arquivo, d.caminho_armazenamento FROM aviso_documentos ad JOIN documentos d ON d.id=ad.documento_id WHERE ad.aviso_id=?",Arrays.asList(aviso)));resp.put("destinatarios",rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso)));HttpResponses.json(ex,200,resp);}
private void reenviarAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso)throws Exception{try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='nao_enviado' WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);if(ps.executeUpdate()==0){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}}enviarAviso(c,emp,cond,aviso,false);detalheAviso(ex,c,cond,aviso);}
private void excluirAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(old==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_documentos WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_destinatarios WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM avisos WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);ps.executeUpdate();}audit(c,fid,"delete","avisos",aviso,old,Map.of("ok",true));c.commit();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("DELETE FROM logs_envio_avisos WHERE empresa_id=? AND condominio_id=? AND aviso_id=?")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
private void enviarAviso(Connection c,long emp,long cond,long aviso,boolean apenasFalhos)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null)return;boolean w=Boolean.TRUE.equals(a.get("enviar_whatsapp"));boolean e=Boolean.TRUE.equals(a.get("enviar_email"));Map<String,Object> inst=w?evolutionAprovada(emp):null;Map<String,String> smtp=e?smtpConfig():Map.of();List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(aviso));boolean allOk=true;int tentativas=0;if(dests.isEmpty())allOk=false;for(Map<String,Object>d:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> contato="funcionario".equals(tipo)?funcionario(emp,did):("extra".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM destinatarios_extra WHERE id=? AND status='ativo'",Arrays.asList(did)):one(c,"SELECT id,nome,telefone,email FROM contatos WHERE id=?",Arrays.asList(did)));if(contato==null){allOk=false;continue;}String nome=String.valueOf(contato.get("nome"));if(w&&deveTentar(emp,aviso,did,"whatsapp",apenasFalhos)){tentativas++;String tel=telefoneWhatsapp(contato.get("telefone"));try{if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,msg(a));logEnvio(emp,cond,aviso,did,nome,"whatsapp","enviado",limite(resposta));}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"whatsapp","falhou",err.getMessage());}}if(e&&deveTentar(emp,aviso,did,"email",apenasFalhos)){tentativas++;String email=str(contato.get("email"));try{if(!email.contains("@"))throw new IllegalArgumentException("email_invalido");emailClient.enviar(smtp,email,str(a.get("titulo")),msg(a));logEnvio(emp,cond,aviso,did,nome,"email","enviado",null);}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"email","falhou",err.getMessage());}}}if(allOk&&tentativas>0&&reagendarAvisoRecorrente(c,aviso,a))return;try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio=?, ultimo_envio=CASE WHEN ? THEN now() ELSE ultimo_envio END WHERE id=?")){boolean ok=allOk&&tentativas>0;ps.setString(1,ok?"enviado":"falhou");ps.setBoolean(2,ok);ps.setLong(3,aviso);ps.executeUpdate();}}
private boolean reagendarAvisoRecorrente(Connection c,long aviso,Map<String,Object>a)throws SQLException{
int meses=a.get("recorrencia_meses")==null?0:((Number)a.get("recorrencia_meses")).intValue();
if(!Boolean.TRUE.equals(a.get("recorrencia_ativa"))||meses<=0)return false;
Timestamp base=(Timestamp)a.get("data_evento");
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
LocalDateTime proximo=inicio.plusMonths(meses);
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
Timestamp prox=Timestamp.valueOf(proximo);
try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='agendado', data_evento=?, ultimo_envio=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,aviso);ps.executeUpdate();}
enfileirarAviso(c,aviso,prox);
return true;
}
private boolean avisoRecorrente(Connection c,long aviso)throws SQLException{Map<String,Object>a=one(c,"SELECT recorrencia_ativa,recorrencia_meses FROM avisos WHERE id=?",Arrays.asList(aviso));return a!=null&&Boolean.TRUE.equals(a.get("recorrencia_ativa"))&&a.get("recorrencia_meses")!=null&&((Number)a.get("recorrencia_meses")).intValue()>0;}
private boolean deveTentar(long emp,long aviso,long contato,String canal,boolean apenasFalhos)throws SQLException{if(!apenasFalhos)return true;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT status FROM logs_envio_avisos WHERE empresa_id=? AND aviso_id=? AND contato_id=? AND canal=? ORDER BY criado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);ps.setLong(2,aviso);ps.setLong(3,contato);ps.setString(4,canal);try(ResultSet rs=ps.executeQuery()){return rs.next()&&"falhou".equals(rs.getString(1));}}}
private void logEnvio(long emp,long cond,long aviso,long contato,String nome,String canal,String status,String erro)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO logs_envio_avisos (empresa_id,condominio_id,aviso_id,contato_id,contato_nome,canal,status,erro_detalhe) VALUES (?,?,?,?,?,?,?,?)")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.setLong(4,contato);ps.setString(5,nome);ps.setString(6,canal);ps.setString(7,status);ps.setString(8,erro);ps.executeUpdate();}}
private boolean funcionalidadeAtiva(long emp,String chave)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT COALESCE(ef.ativa,false) FROM funcionalidades f LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=? WHERE f.chave=?")){ps.setLong(1,emp);ps.setString(2,chave);try(ResultSet rs=ps.executeQuery()){return rs.next()&&rs.getBoolean(1);}}}
private Map<String,Object> evolutionAprovada(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT * FROM evolution_instancias WHERE empresa_id=? AND status='aprovada' ORDER BY aprovado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private Map<String,String> smtpConfig()throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT chave,valor FROM configuracoes_plataforma WHERE chave LIKE 'smtp_%'")){Map<String,String> out=new HashMap<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.put(rs.getString(1),rs.getString(2));}return out;}}
private Map<String,Object> funcionario(long emp,long id)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,id);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
private String telefoneWhatsapp(Object raw){String d=EvolutionApiClient.onlyDigits(str(raw));if((d.length()==10||d.length()==11)&&!d.startsWith("55"))return "55"+d;return d;}
private String limite(String v){if(v==null)return null;return v.length()>900?v.substring(0,900):v;}
private String msg(Map<String,Object>a){return str(a.get("titulo"))+"\n\n"+str(a.get("mensagem"));}
private void concluirFilaLembrete(Connection c,long lembreteId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=\'enviado\', locked_at=NULL, atualizado_em=now() WHERE tipo=\'lembrete\' AND referencia_id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}}
private void enfileirarAviso(Connection c,long avisoId,Timestamp dataHora)throws SQLException{
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('aviso',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
ps.setLong(1,avisoId);
ps.setTimestamp(2,agendado);
ps.executeUpdate();
}
}
private void enfileirarLembrete(Connection c,long lembreteId,Timestamp dataHora)throws SQLException{
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('lembrete',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
ps.setLong(1,lembreteId);
ps.setTimestamp(2,agendado);
ps.executeUpdate();
}
}
public int processarFilaEnvios()throws Exception{
int total=0;
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
try(ResultSet rs=ps.executeQuery()){
while(rs.next()){
long emp=rs.getLong(1);
try(Connection c=tenantDs.getConnection(emp)){
total+=processarFilaEmpresa(c,emp);
}catch(Exception err){
System.err.println("Falha ao processar fila de envio da empresa "+emp+": "+err.getMessage());
}
}
}
}
return total;
}
private int processarFilaEmpresa(Connection c,long emp)throws Exception{
int total=0;
while(true){
Map<String,Object> item=proximoItemFila(c);
if(item==null)return total;
long filaId=((Number)item.get("id")).longValue();
long ref=((Number)item.get("referencia_id")).longValue();
try{
String tipo=str(item.get("tipo"));
if("lembrete".equals(tipo)){
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,ref);
if(lembrete==null||!"ativo".equalsIgnoreCase(str(lembrete.get("status")))){
concluirItemFila(c,filaId,"ignorado",null);
}else{
Map<String,Object> envio=enviarLembreteWhatsApp(emp,ref,lembrete);
int enviados=Number.class.isInstance(envio.get("enviados"))?((Number)envio.get("enviados")).intValue():0;
int falhas=Number.class.isInstance(envio.get("falhas"))?((Number)envio.get("falhas")).intValue():1;
if(enviados>0&&falhas==0){
boolean reagendado=finalizarOuReagendarLembrete(c,ref);
if(!reagendado)concluirItemFila(c,filaId,"enviado",null);
}else{
falharItemFila(c,filaId,str(envio.getOrDefault("mensagem","Falha ao enviar lembrete.")));
}
}
}else if("aviso".equals(tipo)){
Map<String,Object> aviso=one(c,"SELECT id,condominio_id,status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
if(aviso==null||"enviado".equalsIgnoreCase(str(aviso.get("status_envio")))){
concluirItemFila(c,filaId,"ignorado",null);
}else{
long cond=((Number)aviso.get("condominio_id")).longValue();
enviarAviso(c,emp,cond,ref,false);
Map<String,Object> atualizado=one(c,"SELECT status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
if(atualizado!=null&&"enviado".equalsIgnoreCase(str(atualizado.get("status_envio"))))concluirItemFila(c,filaId,"enviado",null);
else if(atualizado!=null&&"agendado".equalsIgnoreCase(str(atualizado.get("status_envio")))&&avisoRecorrente(c,ref)){ }
else falharItemFila(c,filaId,"Falha ao enviar aviso.");
}
}else{
concluirItemFila(c,filaId,"ignorado","Tipo de mensagem desconhecido: "+tipo);
}
total++;
}catch(Exception err){
falharItemFila(c,filaId,err.getMessage());
total++;
}
}
}
private Map<String,Object> proximoItemFila(Connection c)throws SQLException{
c.setAutoCommit(false);
try{
Map<String,Object> item=one(c,"SELECT * FROM fila_envio WHERE status='pendente' AND agendado_para <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY agendado_para,id LIMIT 1 FOR UPDATE SKIP LOCKED",Arrays.asList());
if(item!=null){
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status='processando', locked_at=now(), tentativas=tentativas+1, atualizado_em=now() WHERE id=?")){
ps.setLong(1,((Number)item.get("id")).longValue());
ps.executeUpdate();
}
}
c.commit();
return item;
}catch(Exception err){
c.rollback();
throw err;
}finally{
c.setAutoCommit(true);
}
}
private void concluirItemFila(Connection c,long filaId,String status,String erro)throws SQLException{
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=?, ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
ps.setString(1,status);
ps.setString(2,erro);
ps.setLong(3,filaId);
ps.executeUpdate();
}
}
private void falharItemFila(Connection c,long filaId,String erro)throws SQLException{
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=CASE WHEN tentativas>=3 THEN 'falhou' ELSE 'pendente' END, agendado_para=(now() AT TIME ZONE 'America/Sao_Paulo') + interval '1 minute', ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
ps.setString(1,limite(erro));
ps.setLong(2,filaId);
ps.executeUpdate();
}
}
public int enviarLembretesAgendados()throws Exception{
int total=0;
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
try(ResultSet rs=ps.executeQuery()){
while(rs.next()){
long emp=rs.getLong(1);
try(Connection c=tenantDs.getConnection(emp)){
List<Map<String,Object>> pendentes=rows(c,"SELECT id FROM lembretes WHERE status='ativo' AND data_hora IS NOT NULL AND data_hora <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY data_hora,id",Arrays.asList());
for(Map<String,Object> row:pendentes){
long lembreteId=((Number)row.get("id")).longValue();
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,lembreteId);
if(lembrete==null)continue;
Map<String,Object> envio=enviarLembreteWhatsApp(emp,lembreteId,lembrete);
int enviados=((Number)envio.getOrDefault("enviados",0)).intValue();
int falhas=((Number)envio.getOrDefault("falhas",0)).intValue();
if(enviados>0&&falhas==0)finalizarOuReagendarLembrete(c,lembreteId);
total++;
}
}catch(Exception err){
System.err.println("Falha ao enviar lembretes agendados da empresa "+emp+": "+err.getMessage());
}
}
}
}
return total;
}
private boolean finalizarOuReagendarLembrete(Connection c,long lembreteId)throws SQLException{
Map<String,Object> l=one(c,"SELECT recorrencia_ativa,recorrencia_meses,data_hora FROM lembretes WHERE id=?",Arrays.asList(lembreteId));
int meses=l==null||l.get("recorrencia_meses")==null?0:((Number)l.get("recorrencia_meses")).intValue();
boolean recorrente=l!=null&&Boolean.TRUE.equals(l.get("recorrencia_ativa"))&&meses>0;
if(recorrente){
Timestamp base=(Timestamp)l.get("data_hora");
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
LocalDateTime proximo=inicio.plusMonths(meses);
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
Timestamp prox=Timestamp.valueOf(proximo);
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET data_hora=?, ultimo_envio=now(), status='ativo', atualizado_em=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,lembreteId);ps.executeUpdate();}
enfileirarLembrete(c,lembreteId,prox);
return true;
}
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET status='concluido', ultimo_envio=now(), atualizado_em=now() WHERE id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}
concluirFilaLembrete(c,lembreteId);
return false;
}
public int enviarAvisosAgendados()throws Exception{int total=0;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next()){long emp=rs.getLong(1);try(Connection c=tenantDs.getConnection(emp)){List<Map<String,Object>> pendentes=rows(c,"SELECT id,condominio_id FROM avisos WHERE (enviar_whatsapp=true OR enviar_email=true) AND status_envio IN ('nao_enviado','agendado') AND (data_evento IS NULL OR data_evento<=(now() AT TIME ZONE 'America/Sao_Paulo')) ORDER BY data_evento NULLS FIRST,id",Arrays.asList());for(Map<String,Object>a:pendentes){enviarAviso(c,emp,((Number)a.get("condominio_id")).longValue(),((Number)a.get("id")).longValue(),false);total++;}}catch(Exception err){System.err.println("Falha ao enviar avisos agendados da empresa "+emp+": "+err.getMessage());}}}}return total;}
private void execWrite(HttpExchange ex,Connection c,String sql,List<?>params,long fid,String tabela,long rid)throws Exception{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);ps.executeUpdate();}audit(c,fid,"create",tabela,rid,null,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
private void listar(HttpExchange ex,Connection c,String sql,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList())));}
private void listarParam(HttpExchange ex,Connection c,String sql,long id,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList(id))));}
private long insert(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql,Statement.RETURN_GENERATED_KEYS)){bind(ps,params);ps.executeUpdate();try(ResultSet rs=ps.getGeneratedKeys()){rs.next();return rs.getLong(1);}}}
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{List<Map<String,Object>> r=rows(c,sql,params);return r.isEmpty()?null:r.get(0);} private List<Map<String,Object>> rows(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){List<Map<String,Object>> out=new ArrayList<>();ResultSetMetaData md=rs.getMetaData();while(rs.next()){Map<String,Object> m=new LinkedHashMap<>();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));out.add(m);}return out;}}}
private long count(Connection c,String sql,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private long count(Connection c,String sql,long a,long b)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,a);ps.setLong(2,b);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}
private void audit(Connection c,long fid,String acao,String tabela,long rid,Object old,Object neu)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO log_auditoria (funcionario_id,acao,tabela_afetada,registro_id,dados_anteriores,dados_novos) VALUES (?,?,?,?,?::jsonb,?::jsonb)")){ps.setLong(1,fid);ps.setString(2,acao);ps.setString(3,tabela);ps.setLong(4,rid);ps.setString(5,old==null?null:Json.write(old));ps.setString(6,neu==null?null:Json.write(neu));ps.executeUpdate();}}
private void bind(PreparedStatement ps,List<?>params)throws SQLException{for(int i=0;i<params.size();i++){Object v=params.get(i); if(v==null)ps.setObject(i+1,null); else ps.setObject(i+1,v);}}
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));} private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);} private String req(Map<String,String>m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;} private Object nullable(String v){return v==null||v.isBlank()?null:v;} private Long optLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);} private long id(String s){return Long.parseLong(s);} private void setDate(PreparedStatement ps,int i,String v)throws SQLException{if(v==null||v.isBlank())ps.setNull(i,Types.DATE);else ps.setDate(i,java.sql.Date.valueOf(LocalDate.parse(v)));}
private Timestamp ts(String v){
if(v==null||v.isBlank())return null;
String x=v.trim().replace('T',' ').replace(",", " ").replaceAll("\\s+", " ");
try{
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"))x=x+":00";
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"))return Timestamp.valueOf(x);
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"));return Timestamp.valueOf(dt);}
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"));return Timestamp.valueOf(dt);}
}catch(Exception ignored){}
throw new IllegalArgumentException("Data/hora invalida. Use dd/mm/aaaa hh:mm.");
} private int recorrenciaMeses(String v){if(v==null||v.isBlank())return 0;int n=Integer.parseInt(v);if(n==0||n==1||n==3||n==12)return n;throw new IllegalArgumentException("Recorrencia invalida. Use 0, 1, 3 ou 12 meses.");} private boolean bool(String v){return "true".equalsIgnoreCase(v)||"1".equals(v)||"sim".equalsIgnoreCase(v);} private String str(Object v){return v==null?"":String.valueOf(v);} private String cnpj(String v){String d=v==null?"":v.replaceAll("\\D","");if(d.length()!=14)throw new IllegalArgumentException("CNPJ invalido. Informe 14 digitos.");return d;}
private List<Long> longArray(String raw,String key){String a=arrayBody(raw,key);if(a.isBlank())return List.of();List<Long> out=new ArrayList<>();for(String x:a.split(",")){String v=x.trim().replace("\"","");if(!v.isBlank())out.add(Long.parseLong(v));}return out;}
private List<Destinatario> destinatarios(String raw){String a=arrayBody(raw,"destinatarios");if(a.isBlank())return List.of();List<Destinatario> out=new ArrayList<>();for(String obj:a.split("\\},\\s*\\{")){String x=obj.replace("{","").replace("}","");String tipo=null;Long id=null;for(String kv:x.split(",")){String[]p=kv.split(":",2);if(p.length<2)continue;String k=p[0].trim().replace("\"","");String v=p[1].trim().replace("\"","");if("tipo".equals(k)||"tipo_destinatario".equals(k))tipo=v;if("id".equals(k)||"destinatario_id".equals(k))id=Long.parseLong(v);}if(tipo!=null&&id!=null)out.add(new Destinatario(tipo,id));}return out;}
private String arrayBody(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return"";int s=raw.indexOf('[',p);if(s<0)return"";int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(s+1,i).trim();}return"";}
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int s=raw.indexOf('[',p);if(s<0)return raw;int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(0,s)+"\"\""+raw.substring(i+1);}return raw;} record Destinatario(String tipo,long id){}
private Map<String,String> query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String part:raw.split("&")){String[]kv=part.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
private String databaseName(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT database_name FROM empresas WHERE id=?")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getString(1);}}}
private String ext(String name){int p=name.lastIndexOf('.');return p<0?"":name.substring(p+1).toLowerCase();}
private Multipart parseMultipart(HttpExchange ex)throws IOException{String ct=ex.getRequestHeaders().getFirst("Content-Type");if(ct==null||!ct.contains("boundary="))throw new IllegalArgumentException("multipart/form-data esperado");String boundary="--"+ct.substring(ct.indexOf("boundary=")+9);byte[] body=ex.getRequestBody().readAllBytes();String text=new String(body,StandardCharsets.ISO_8859_1);String filename=null,tipo=null;byte[] fileBytes=null;for(String part:text.split(java.util.regex.Pattern.quote(boundary))){if(!part.contains("Content-Disposition"))continue;int hEnd=part.indexOf("\r\n\r\n");if(hEnd<0)continue;String headers=part.substring(0,hEnd);String content=part.substring(hEnd+4);if(content.endsWith("\r\n"))content=content.substring(0,content.length()-2);if(headers.contains("name=\"tipo\""))tipo=content.trim();if(headers.contains("name=\"arquivo\"")){int fp=headers.indexOf("filename=\"");if(fp>=0){int fe=headers.indexOf('"',fp+10);filename=headers.substring(fp+10,fe);}fileBytes=content.getBytes(StandardCharsets.ISO_8859_1);}}if(filename==null||fileBytes==null)throw new IllegalArgumentException("arquivo obrigatorio");return new Multipart(filename,tipo,fileBytes);} record Multipart(String filename,String tipo,byte[]bytes){}
}
@@ -0,0 +1,6 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDateTime;
public record Condominio(Long id, String nome, String cnpj, String endereco, LocalDateTime criadoEm) {
}
@@ -0,0 +1,6 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDateTime;
public record Contato(Long id, String nome, String telefone, String email, LocalDateTime criadoEm) {
}
@@ -0,0 +1,16 @@
package com.seconectai.condocontab.tenant.model;
import java.time.LocalDate;
public record ContatoCondominio(
Long contatoId,
Long condominioId,
String tipo,
LocalDate dataInicioMandato,
LocalDate dataFimMandato,
String status,
String contatoNome,
String contatoTelefone,
String contatoEmail
) {
}
@@ -0,0 +1,83 @@
package com.seconectai.condocontab.tenant.provisioning;
import com.seconectai.condocontab.config.AppConfig;
import com.seconectai.condocontab.db.MigrationRunner;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
public final class TenantProvisioner {
private final AppConfig config;
public TenantProvisioner(AppConfig config) {
this.config = config;
}
public ResultadoProvisionamento provisionar(String nomeEmpresa, String slug) {
String databaseName;
try {
databaseName = databaseNameFromSlug(slug);
} catch (IllegalArgumentException e) {
return ResultadoProvisionamento.falha(null, e.getMessage());
}
try {
criarDatabaseSeNaoExistir(databaseName);
try (var tenantConn = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword())) {
MigrationRunner.runMigrations(tenantConn, "migrations/tenant");
}
System.out.println("Tenant provisionado para " + nomeEmpresa + ": " + databaseName);
return ResultadoProvisionamento.sucesso(databaseName);
} catch (Exception e) {
System.err.println("Falha ao provisionar tenant " + databaseName + ": " + e.getMessage());
return ResultadoProvisionamento.falha(databaseName, e.getMessage());
}
}
public String databaseNameFromSlug(String slug) {
if (slug == null || slug.isBlank()) {
throw new IllegalArgumentException("Slug obrigatorio para provisionamento do tenant");
}
if (!slug.matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Slug invalido: use apenas letras minusculas, numeros, hifen ou underscore");
}
String normalized = slug.replace('-', '_');
if (!normalized.matches("[a-z0-9_]+")) {
throw new IllegalArgumentException("Slug invalido apos normalizacao");
}
return "db_empresa_" + normalized;
}
private void criarDatabaseSeNaoExistir(String databaseName) throws SQLException {
try (var conn = DriverManager.getConnection(config.postgresJdbcUrl(), config.dbUser(), config.dbPassword())) {
conn.setAutoCommit(true);
if (databaseExiste(conn.createStatement(), databaseName)) {
return;
}
try (Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + quoteIdentifier(databaseName));
}
}
}
private boolean databaseExiste(Statement stmt, String databaseName) throws SQLException {
try (var rs = stmt.executeQuery("SELECT 1 FROM pg_database WHERE datname = '" + databaseName + "'")) {
return rs.next();
}
}
private String quoteIdentifier(String identifier) {
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
public record ResultadoProvisionamento(boolean sucesso, String databaseName, String erro) {
public static ResultadoProvisionamento sucesso(String databaseName) {
return new ResultadoProvisionamento(true, databaseName, null);
}
public static ResultadoProvisionamento falha(String databaseName, String erro) {
return new ResultadoProvisionamento(false, databaseName, erro);
}
}
}
@@ -0,0 +1,65 @@
package com.seconectai.condocontab.util;
import java.time.temporal.TemporalAccessor;
import java.util.Iterator;
import java.util.Map;
public final class Json {
private Json() {
}
public static String write(Object value) {
return value(value);
}
private static String value(Object value) {
if (value == null) {
return "null";
}
if (value instanceof Number || value instanceof Boolean) {
return String.valueOf(value);
}
if (value instanceof TemporalAccessor) {
return quote(String.valueOf(value));
}
if (value instanceof Map<?, ?> map) {
StringBuilder json = new StringBuilder("{");
Iterator<? extends Map.Entry<?, ?>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
json.append(quote(String.valueOf(entry.getKey()))).append(":").append(value(entry.getValue()));
if (iterator.hasNext()) {
json.append(",");
}
}
return json.append("}").toString();
}
if (value instanceof Iterable<?> iterable) {
StringBuilder json = new StringBuilder("[");
Iterator<?> iterator = iterable.iterator();
while (iterator.hasNext()) {
json.append(value(iterator.next()));
if (iterator.hasNext()) {
json.append(",");
}
}
return json.append("]").toString();
}
return quote(String.valueOf(value));
}
private static String quote(String text) {
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> out.append(c);
}
}
return out.append("\"").toString();
}
}
@@ -0,0 +1,116 @@
package com.seconectai.condocontab.util;
import java.util.LinkedHashMap;
import java.util.Map;
public final class SimpleJson {
private SimpleJson() {
}
public static Map<String, String> parseObject(String json) {
Parser parser = new Parser(json);
return parser.parseObject();
}
private static final class Parser {
private final String text;
private int pos;
private Parser(String text) {
this.text = text == null ? "" : text;
}
private Map<String, String> parseObject() {
Map<String, String> values = new LinkedHashMap<>();
skipWs();
expect('{');
skipWs();
if (peek() == '}') {
pos++;
return values;
}
while (true) {
skipWs();
String key = parseString();
skipWs();
expect(':');
skipWs();
values.put(key, parseValueAsString());
skipWs();
char c = peek();
if (c == ',') {
pos++;
continue;
}
if (c == '}') {
pos++;
return values;
}
throw new IllegalArgumentException("JSON invalido na posicao " + pos);
}
}
private String parseValueAsString() {
char c = peek();
if (c == '"') {
return parseString();
}
int start = pos;
while (pos < text.length() && ",}".indexOf(text.charAt(pos)) < 0) {
pos++;
}
return text.substring(start, pos).trim();
}
private String parseString() {
expect('"');
StringBuilder out = new StringBuilder();
while (pos < text.length()) {
char c = text.charAt(pos++);
if (c == '"') {
return out.toString();
}
if (c == '\\') {
if (pos >= text.length()) {
throw new IllegalArgumentException("Escape JSON invalido");
}
char escaped = text.charAt(pos++);
switch (escaped) {
case '"' -> out.append('"');
case '\\' -> out.append('\\');
case '/' -> out.append('/');
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
default -> throw new IllegalArgumentException("Escape JSON nao suportado: " + escaped);
}
} else {
out.append(c);
}
}
throw new IllegalArgumentException("String JSON nao finalizada");
}
private void expect(char expected) {
if (peek() != expected) {
throw new IllegalArgumentException("Esperado '" + expected + "' na posicao " + pos);
}
pos++;
}
private char peek() {
if (pos >= text.length()) {
return '\0';
}
return text.charAt(pos);
}
private void skipWs() {
while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
pos++;
}
}
}
}