Initial project import
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package com.seconectai.condocontab;
|
||||
|
||||
import com.seconectai.condocontab.auth.ApiKeyService;
|
||||
import com.seconectai.condocontab.auth.JwtService;
|
||||
import com.seconectai.condocontab.auth.http.AuthHandlers;
|
||||
import com.seconectai.condocontab.auth.http.AuthMiddleware;
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
import com.seconectai.condocontab.db.MigrationRunner;
|
||||
import com.seconectai.condocontab.financeiro.AbacatePayClient;
|
||||
import com.seconectai.condocontab.financeiro.AbacatePayWebhookHandler;
|
||||
import com.seconectai.condocontab.financeiro.BloqueioJob;
|
||||
import com.seconectai.condocontab.financeiro.FinanceiroHandlers;
|
||||
import com.seconectai.condocontab.http.HttpResponses;
|
||||
import com.seconectai.condocontab.http.Router;
|
||||
import com.seconectai.condocontab.http.StaticFileHandler;
|
||||
import com.seconectai.condocontab.integracao.EmailClient;
|
||||
import com.seconectai.condocontab.integracao.EvolutionApiClient;
|
||||
import com.seconectai.condocontab.master.http.MasterHandlers;
|
||||
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
|
||||
import com.seconectai.condocontab.tenant.http.ClienteHandlers;
|
||||
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class Main {
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
AppConfig config = AppConfig.fromEnv();
|
||||
DataSourceProvider dataSourceProvider = new DataSourceProvider(config);
|
||||
dataSourceProvider.aguardarConexaoMaster(30, 1000);
|
||||
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
MigrationRunner.runMigrations(conn, "migrations/master");
|
||||
}
|
||||
|
||||
JwtService jwtService = new JwtService(config.jwtSecret());
|
||||
ApiKeyService apiKeyService = new ApiKeyService(dataSourceProvider);
|
||||
AbacatePayClient abacatePayClient = new AbacatePayClient(config, dataSourceProvider);
|
||||
EvolutionApiClient evolutionApiClient = new EvolutionApiClient(config);
|
||||
EmailClient emailClient = new EmailClient();
|
||||
AuthHandlers authHandlers = new AuthHandlers(config, dataSourceProvider, jwtService);
|
||||
AuthMiddleware authMiddleware = new AuthMiddleware(jwtService, apiKeyService);
|
||||
TenantDataSourceProvider tenantDataSourceProvider = new TenantDataSourceProvider(config, dataSourceProvider);
|
||||
MasterHandlers masterHandlers = new MasterHandlers(dataSourceProvider, new TenantProvisioner(config), evolutionApiClient);
|
||||
ClienteHandlers clienteHandlers = new ClienteHandlers(tenantDataSourceProvider, dataSourceProvider, Path.of("/app/uploads"), evolutionApiClient, emailClient);
|
||||
FinanceiroHandlers financeiroHandlers = new FinanceiroHandlers(dataSourceProvider, abacatePayClient);
|
||||
AbacatePayWebhookHandler abacatePayWebhookHandler = new AbacatePayWebhookHandler(dataSourceProvider, abacatePayClient);
|
||||
|
||||
if ("worker".equalsIgnoreCase(System.getenv().getOrDefault("APP_MODE", "web"))) {
|
||||
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
int processados = clienteHandlers.processarFilaEnvios();
|
||||
if (processados > 0) System.out.println("Mensageiro processou " + processados + " mensagens");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}, 2, 5, TimeUnit.SECONDS);
|
||||
System.out.println("Mensageiro de envios iniciado");
|
||||
new CountDownLatch(1).await();
|
||||
return;
|
||||
}
|
||||
|
||||
Router router = new Router();
|
||||
router.get("/health", Main::health);
|
||||
router.post("/api/master/login", authHandlers.masterLogin());
|
||||
router.post("/api/auth/login", authHandlers.funcionarioLogin());
|
||||
router.post("/api/master/webhooks/abacatepay", abacatePayWebhookHandler);
|
||||
router.prefix("/api/master/", authMiddleware.exigirMaster(masterHandlers));
|
||||
router.prefix("/api/financeiro/", authMiddleware.exigirFuncionario(financeiroHandlers));
|
||||
router.prefix("/api/", authMiddleware.exigirFuncionario(clienteHandlers));
|
||||
router.fallback(new StaticFileHandler());
|
||||
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(config.httpPort()), 0);
|
||||
server.createContext("/", router);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
server.start();
|
||||
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(new BloqueioJob(dataSourceProvider), 1, 24 * 60 * 60, TimeUnit.SECONDS);
|
||||
|
||||
System.out.println("Servidor HTTP iniciado na porta " + config.httpPort());
|
||||
} catch (Exception e) {
|
||||
System.err.println("Falha ao iniciar aplicacao: " + e.getMessage());
|
||||
e.printStackTrace(System.err);
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void health(HttpExchange exchange) throws java.io.IOException {
|
||||
if (!"GET".equals(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
HttpResponses.json(exchange, 200, Map.of("status", "ok"));
|
||||
}
|
||||
|
||||
private static void apiNaoImplementada(HttpExchange exchange) throws java.io.IOException {
|
||||
HttpResponses.json(exchange, 404, Map.of("erro", "endpoint_nao_implementado"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.seconectai.condocontab.auth;
|
||||
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
public final class ApiKeyService {
|
||||
private final DataSourceProvider dataSourceProvider;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public ApiKeyService(DataSourceProvider dataSourceProvider) {
|
||||
this.dataSourceProvider = dataSourceProvider;
|
||||
}
|
||||
|
||||
public ChaveGerada gerar() {
|
||||
byte[] random = new byte[32];
|
||||
secureRandom.nextBytes(random);
|
||||
String chave = "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
return new ChaveGerada(chave, hash(chave));
|
||||
}
|
||||
|
||||
public OptionalLong validar(String chaveRecebida) throws SQLException {
|
||||
if (chaveRecebida == null || chaveRecebida.isBlank()) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
String keyHash = hash(chaveRecebida);
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection();
|
||||
PreparedStatement ps = conn.prepareStatement("SELECT empresa_id FROM api_keys WHERE key_hash = ? AND ativa = true")) {
|
||||
ps.setString(1, keyHash);
|
||||
try (var rs = ps.executeQuery()) {
|
||||
return rs.next() ? OptionalLong.of(rs.getLong("empresa_id")) : OptionalLong.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String hash(String chave) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(chave.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Falha ao gerar hash de API Key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public record ChaveGerada(String chave, String hash) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.seconectai.condocontab.auth;
|
||||
|
||||
import com.seconectai.condocontab.util.Json;
|
||||
import com.seconectai.condocontab.util.SimpleJson;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class JwtService {
|
||||
private final byte[] secret;
|
||||
private final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
private final Base64.Decoder decoder = Base64.getUrlDecoder();
|
||||
|
||||
public JwtService(String secret) {
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalArgumentException("JWT_SECRET obrigatorio");
|
||||
}
|
||||
this.secret = secret.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String gerar(Map<String, Object> claims, long validadeSegundos) {
|
||||
Map<String, Object> header = Map.of("alg", "HS256", "typ", "JWT");
|
||||
Map<String, Object> payload = new LinkedHashMap<>(claims);
|
||||
payload.put("exp", Instant.now().getEpochSecond() + validadeSegundos);
|
||||
String headerPart = encode(Json.write(header));
|
||||
String payloadPart = encode(Json.write(payload));
|
||||
String signingInput = headerPart + "." + payloadPart;
|
||||
return signingInput + "." + encoder.encodeToString(hmac(signingInput));
|
||||
}
|
||||
|
||||
public Optional<Map<String, Object>> validar(String token) {
|
||||
try {
|
||||
if (token == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length != 3) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String signingInput = parts[0] + "." + parts[1];
|
||||
String expected = encoder.encodeToString(hmac(signingInput));
|
||||
if (!constantTimeEquals(expected, parts[2])) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String payloadJson = new String(decoder.decode(parts[1]), StandardCharsets.UTF_8);
|
||||
Map<String, String> parsed = SimpleJson.parseObject(payloadJson);
|
||||
long exp = Long.parseLong(parsed.getOrDefault("exp", "0"));
|
||||
if (Instant.now().getEpochSecond() >= exp) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, Object> claims = new LinkedHashMap<>();
|
||||
parsed.forEach((k, v) -> claims.put(k, parseClaim(v)));
|
||||
return Optional.of(claims);
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Object parseClaim(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.matches("-?\\d+")) {
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if ("true".equals(value) || "false".equals(value)) {
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String encode(String text) {
|
||||
return encoder.encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private byte[] hmac(String text) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||
return mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Falha ao assinar JWT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String a, String b) {
|
||||
byte[] left = a.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] right = b.getBytes(StandardCharsets.UTF_8);
|
||||
if (left.length != right.length) {
|
||||
return false;
|
||||
}
|
||||
int diff = 0;
|
||||
for (int i = 0; i < left.length; i++) {
|
||||
diff |= left[i] ^ right[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.seconectai.condocontab.auth;
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt;
|
||||
|
||||
public final class PasswordHasher {
|
||||
private PasswordHasher() {
|
||||
}
|
||||
|
||||
public static String hash(String senha) {
|
||||
return BCrypt.withDefaults().hashToString(12, senha.toCharArray());
|
||||
}
|
||||
|
||||
public static boolean verificar(String senha, String hash) {
|
||||
if (senha == null || hash == null || hash.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return BCrypt.verifyer().verify(senha.toCharArray(), hash.toCharArray()).verified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.seconectai.condocontab.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class RateLimiter {
|
||||
private final Map<String, Deque<Long>> tentativas = new ConcurrentHashMap<>();
|
||||
private final int maxTentativas;
|
||||
private final long janelaMillis;
|
||||
private final Clock clock;
|
||||
|
||||
public RateLimiter(int maxTentativas, long janelaMillis) {
|
||||
this(maxTentativas, janelaMillis, Clock.systemUTC());
|
||||
}
|
||||
|
||||
RateLimiter(int maxTentativas, long janelaMillis, Clock clock) {
|
||||
this.maxTentativas = maxTentativas;
|
||||
this.janelaMillis = janelaMillis;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public boolean permitir(String chave) {
|
||||
long agora = clock.millis();
|
||||
Deque<Long> fila = tentativas.computeIfAbsent(chave, ignored -> new ArrayDeque<>());
|
||||
synchronized (fila) {
|
||||
while (!fila.isEmpty() && agora - fila.peekFirst() > janelaMillis) {
|
||||
fila.removeFirst();
|
||||
}
|
||||
if (fila.size() >= maxTentativas) {
|
||||
return false;
|
||||
}
|
||||
fila.addLast(agora);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.seconectai.condocontab.auth.http;
|
||||
|
||||
import com.seconectai.condocontab.auth.JwtService;
|
||||
import com.seconectai.condocontab.auth.PasswordHasher;
|
||||
import com.seconectai.condocontab.auth.RateLimiter;
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
import com.seconectai.condocontab.http.HttpResponses;
|
||||
import com.seconectai.condocontab.master.dao.EmpresaDao;
|
||||
import com.seconectai.condocontab.master.dao.FuncionarioDao;
|
||||
import com.seconectai.condocontab.master.model.Empresa;
|
||||
import com.seconectai.condocontab.master.model.Funcionario;
|
||||
import com.seconectai.condocontab.util.SimpleJson;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AuthHandlers {
|
||||
private static final long MASTER_VALIDADE_SEGUNDOS = 2 * 60 * 60;
|
||||
private static final long FUNCIONARIO_VALIDADE_SEGUNDOS = 8 * 60 * 60;
|
||||
|
||||
private final AppConfig config;
|
||||
private final DataSourceProvider dataSourceProvider;
|
||||
private final JwtService jwtService;
|
||||
private final RateLimiter rateLimiter;
|
||||
|
||||
public AuthHandlers(AppConfig config, DataSourceProvider dataSourceProvider, JwtService jwtService) {
|
||||
this.config = config;
|
||||
this.dataSourceProvider = dataSourceProvider;
|
||||
this.jwtService = jwtService;
|
||||
this.rateLimiter = new RateLimiter(5, 5 * 60 * 1000L);
|
||||
}
|
||||
|
||||
public HttpHandler masterLogin() {
|
||||
return exchange -> {
|
||||
if (!"POST".equals(exchange.getRequestMethod())) {
|
||||
HttpResponses.methodNotAllowed(exchange);
|
||||
return;
|
||||
}
|
||||
String ip = clientIp(exchange);
|
||||
if (!rateLimiter.permitir("master:" + ip)) {
|
||||
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
|
||||
return;
|
||||
}
|
||||
Map<String, String> body = body(exchange);
|
||||
String senha = body.get("senha");
|
||||
if (!PasswordHasher.verificar(senha, config.masterPasswordHash())) {
|
||||
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
|
||||
return;
|
||||
}
|
||||
String token = jwtService.gerar(Map.of("escopo", "master"), MASTER_VALIDADE_SEGUNDOS);
|
||||
HttpResponses.json(exchange, 200, Map.of("token", token));
|
||||
};
|
||||
}
|
||||
|
||||
public HttpHandler funcionarioLogin() {
|
||||
return exchange -> {
|
||||
if (!"POST".equals(exchange.getRequestMethod())) {
|
||||
HttpResponses.methodNotAllowed(exchange);
|
||||
return;
|
||||
}
|
||||
String ip = clientIp(exchange);
|
||||
if (!rateLimiter.permitir("funcionario:" + ip)) {
|
||||
HttpResponses.json(exchange, 429, Map.of("erro", "muitas_tentativas"));
|
||||
return;
|
||||
}
|
||||
Map<String, String> body = body(exchange);
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
Funcionario funcionario = new FuncionarioDao(conn).buscarPorEmail(body.get("email")).orElse(null);
|
||||
if (funcionario == null || !PasswordHasher.verificar(body.get("senha"), funcionario.senhaHash())) {
|
||||
HttpResponses.json(exchange, 401, Map.of("erro", "credenciais_invalidas"));
|
||||
return;
|
||||
}
|
||||
if (!"ativo".equals(funcionario.status())) {
|
||||
HttpResponses.json(exchange, 403, Map.of("erro", "cadastro_desativado", "mensagem", "Cadastro de funcionario desativado."));
|
||||
return;
|
||||
}
|
||||
Empresa empresa = new EmpresaDao(conn).buscarPorId(funcionario.empresaId()).orElse(null);
|
||||
if (empresa == null || !("ativa".equals(empresa.status()) || "bloqueada".equals(empresa.status()))) {
|
||||
HttpResponses.json(exchange, 403, Map.of("erro", "empresa_inativa", "mensagem", "Escritorio nao esta ativo."));
|
||||
return;
|
||||
}
|
||||
Map<String, Object> claims = new LinkedHashMap<>();
|
||||
claims.put("escopo", "empresa");
|
||||
claims.put("empresa_id", funcionario.empresaId());
|
||||
claims.put("funcionario_id", funcionario.id());
|
||||
claims.put("cargo", funcionario.cargo() == null ? "" : funcionario.cargo());
|
||||
String token = jwtService.gerar(claims, FUNCIONARIO_VALIDADE_SEGUNDOS);
|
||||
HttpResponses.json(exchange, 200, Map.of("token", token));
|
||||
} catch (Exception e) {
|
||||
HttpResponses.json(exchange, 500, Map.of("erro", "erro_login"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, String> body(HttpExchange exchange) throws IOException {
|
||||
String text = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
try {
|
||||
return SimpleJson.parseObject(text);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String clientIp(HttpExchange exchange) {
|
||||
String forwarded = exchange.getRequestHeaders().getFirst("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return exchange.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.seconectai.condocontab.auth.http;
|
||||
|
||||
import com.seconectai.condocontab.auth.ApiKeyService;
|
||||
import com.seconectai.condocontab.auth.JwtService;
|
||||
import com.seconectai.condocontab.auth.ApiKeyService;
|
||||
import com.seconectai.condocontab.http.HttpResponses;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AuthMiddleware {
|
||||
public static final String ATTR_EMPRESA_ID = "empresa_id";
|
||||
public static final String ATTR_FUNCIONARIO_ID = "funcionario_id";
|
||||
public static final String ATTR_CARGO = "cargo";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final ApiKeyService apiKeyService;
|
||||
|
||||
public AuthMiddleware(JwtService jwtService, ApiKeyService apiKeyService) {
|
||||
this.jwtService = jwtService;
|
||||
this.apiKeyService = apiKeyService;
|
||||
}
|
||||
|
||||
public HttpHandler exigirMaster(HttpHandler next) {
|
||||
return exchange -> {
|
||||
var claims = bearerClaims(exchange);
|
||||
if (claims == null || !"master".equals(claims.get("escopo"))) {
|
||||
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
|
||||
return;
|
||||
}
|
||||
next.handle(exchange);
|
||||
};
|
||||
}
|
||||
|
||||
public HttpHandler exigirFuncionario(HttpHandler next) {
|
||||
return exchange -> {
|
||||
var claims = bearerClaims(exchange);
|
||||
if (claims == null || !"empresa".equals(claims.get("escopo"))) {
|
||||
HttpResponses.json(exchange, 401, Map.of("erro", "nao_autorizado"));
|
||||
return;
|
||||
}
|
||||
exchange.setAttribute(ATTR_EMPRESA_ID, toLong(claims.get("empresa_id")));
|
||||
exchange.setAttribute(ATTR_FUNCIONARIO_ID, toLong(claims.get("funcionario_id")));
|
||||
exchange.setAttribute(ATTR_CARGO, String.valueOf(claims.getOrDefault("cargo", "")));
|
||||
Long empresaId = toLong(claims.get("empresa_id"));
|
||||
if (empresaBloqueada(empresaId) && !exchange.getRequestURI().getPath().startsWith("/api/financeiro/")) {
|
||||
HttpResponses.json(exchange, 403, Map.of("erro", "pendencia_financeira", "mensagem", "Seu acesso esta bloqueado por pendencia de pagamento."));
|
||||
return;
|
||||
}
|
||||
next.handle(exchange);
|
||||
};
|
||||
}
|
||||
|
||||
public HttpHandler exigirApiKey(HttpHandler next) {
|
||||
return exchange -> {
|
||||
String chave = exchange.getRequestHeaders().getFirst("X-API-Key");
|
||||
try {
|
||||
var empresaId = apiKeyService.validar(chave);
|
||||
if (empresaId.isEmpty()) {
|
||||
HttpResponses.json(exchange, 401, Map.of("erro", "api_key_invalida"));
|
||||
return;
|
||||
}
|
||||
exchange.setAttribute(ATTR_EMPRESA_ID, empresaId.getAsLong());
|
||||
next.handle(exchange);
|
||||
} catch (SQLException e) {
|
||||
HttpResponses.json(exchange, 500, Map.of("erro", "erro_validacao_api_key"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, Object> bearerClaims(HttpExchange exchange) throws IOException {
|
||||
String auth = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (auth == null || !auth.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
return jwtService.validar(auth.substring("Bearer ".length()).trim()).orElse(null);
|
||||
}
|
||||
|
||||
private boolean empresaBloqueada(long empresaId) {
|
||||
try (var conn = apiKeyServiceMasterConnection();
|
||||
var ps = conn.prepareStatement("SELECT status FROM empresas WHERE id=?")) {
|
||||
ps.setLong(1, empresaId);
|
||||
try (var rs = ps.executeQuery()) { return rs.next() && "bloqueada".equals(rs.getString(1)); }
|
||||
} catch (Exception e) { return false; }
|
||||
}
|
||||
|
||||
private java.sql.Connection apiKeyServiceMasterConnection() throws java.sql.SQLException {
|
||||
try {
|
||||
var f = ApiKeyService.class.getDeclaredField("dataSourceProvider");
|
||||
f.setAccessible(true);
|
||||
var ds = (com.seconectai.condocontab.config.DataSourceProvider) f.get(apiKeyService);
|
||||
return ds.getMasterConnection();
|
||||
} catch (ReflectiveOperationException e) { throw new java.sql.SQLException(e); }
|
||||
}
|
||||
|
||||
private long toLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.seconectai.condocontab.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public final class AppConfig {
|
||||
private final String dbHost;
|
||||
private final int dbPort;
|
||||
private final String dbUser;
|
||||
private final String dbPassword;
|
||||
private final String dbMasterName;
|
||||
private final int httpPort;
|
||||
private final String masterPasswordHash;
|
||||
private final String jwtSecret;
|
||||
private final String abacatePayApiKey;
|
||||
private final String abacatePayWebhookSecret;
|
||||
private final String appPublicUrl;
|
||||
private final String evolutionGlobalApiKey;
|
||||
private final String evolutionApiUrl;
|
||||
|
||||
private AppConfig(String dbHost, int dbPort, String dbUser, String dbPassword, String dbMasterName,
|
||||
int httpPort, String masterPasswordHash, String jwtSecret, String abacatePayApiKey,
|
||||
String abacatePayWebhookSecret, String appPublicUrl, String evolutionGlobalApiKey, String evolutionApiUrl) {
|
||||
this.dbHost = dbHost;
|
||||
this.dbPort = dbPort;
|
||||
this.dbUser = dbUser;
|
||||
this.dbPassword = dbPassword;
|
||||
this.dbMasterName = dbMasterName;
|
||||
this.httpPort = httpPort;
|
||||
this.masterPasswordHash = masterPasswordHash;
|
||||
this.jwtSecret = jwtSecret;
|
||||
this.abacatePayApiKey = abacatePayApiKey;
|
||||
this.abacatePayWebhookSecret = abacatePayWebhookSecret;
|
||||
this.appPublicUrl = appPublicUrl;
|
||||
this.evolutionGlobalApiKey = evolutionGlobalApiKey;
|
||||
this.evolutionApiUrl = evolutionApiUrl;
|
||||
}
|
||||
|
||||
public static AppConfig fromEnv() {
|
||||
Map<String, String> env = System.getenv();
|
||||
return new AppConfig(
|
||||
value(env, "DB_HOST", "localhost"),
|
||||
intValue(env, "DB_PORT", 5432),
|
||||
required(env, "DB_USER"),
|
||||
required(env, "DB_PASSWORD"),
|
||||
value(env, "DB_MASTER_NAME", "db_master"),
|
||||
intValue(env, "HTTP_PORT", 8080),
|
||||
required(env, "MASTER_PASSWORD_HASH"),
|
||||
required(env, "JWT_SECRET"),
|
||||
value(env, "ABACATEPAY_API_KEY", ""),
|
||||
value(env, "ABACATEPAY_WEBHOOK_SECRET", "dev_webhook_secret"),
|
||||
value(env, "APP_PUBLIC_URL", "http://localhost:8080"),
|
||||
value(env, "EVOLUTION_GLOBAL_API_KEY", ""),
|
||||
value(env, "EVOLUTION_API_URL", "mock://evolution")
|
||||
);
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException("Variavel de ambiente obrigatoria ausente: " + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String value(Map<String, String> env, String name, String defaultValue) {
|
||||
String value = env.get(name);
|
||||
return value == null || value.isBlank() ? defaultValue : value;
|
||||
}
|
||||
|
||||
private static int intValue(Map<String, String> env, String name, int defaultValue) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Integer.parseInt(value);
|
||||
}
|
||||
|
||||
public String masterJdbcUrl() {
|
||||
return jdbcUrl(dbMasterName);
|
||||
}
|
||||
|
||||
public String postgresJdbcUrl() {
|
||||
return jdbcUrl("postgres");
|
||||
}
|
||||
|
||||
public String jdbcUrl(String databaseName) {
|
||||
return "jdbc:postgresql://" + dbHost + ":" + dbPort + "/" + databaseName;
|
||||
}
|
||||
|
||||
public String dbUser() {
|
||||
return dbUser;
|
||||
}
|
||||
|
||||
public String dbPassword() {
|
||||
return dbPassword;
|
||||
}
|
||||
|
||||
public int httpPort() {
|
||||
return httpPort;
|
||||
}
|
||||
|
||||
public String masterPasswordHash() {
|
||||
return masterPasswordHash;
|
||||
}
|
||||
|
||||
public String jwtSecret() { return jwtSecret; }
|
||||
public String abacatePayApiKey() { return abacatePayApiKey; }
|
||||
public String abacatePayWebhookSecret() { return abacatePayWebhookSecret; }
|
||||
public String appPublicUrl() { return appPublicUrl; }
|
||||
public String evolutionGlobalApiKey() { return evolutionGlobalApiKey; }
|
||||
public String evolutionApiUrl() { return evolutionApiUrl; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.seconectai.condocontab.config;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class DataSourceProvider {
|
||||
private final AppConfig config;
|
||||
|
||||
public DataSourceProvider(AppConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public Connection getMasterConnection() throws SQLException {
|
||||
return DriverManager.getConnection(config.masterJdbcUrl(), config.dbUser(), config.dbPassword());
|
||||
}
|
||||
|
||||
public void testarConexaoMaster() throws SQLException {
|
||||
try (Connection conn = getMasterConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("SELECT 1");
|
||||
System.out.println("Conexao com db_master OK");
|
||||
}
|
||||
}
|
||||
|
||||
public void aguardarConexaoMaster(int tentativas, long intervaloMillis) throws SQLException, InterruptedException {
|
||||
SQLException ultimoErro = null;
|
||||
for (int tentativa = 1; tentativa <= tentativas; tentativa++) {
|
||||
try {
|
||||
testarConexaoMaster();
|
||||
return;
|
||||
} catch (SQLException e) {
|
||||
ultimoErro = e;
|
||||
System.out.println("Aguardando db_master ficar disponivel (" + tentativa + "/" + tentativas + ")");
|
||||
Thread.sleep(intervaloMillis);
|
||||
}
|
||||
}
|
||||
throw ultimoErro;
|
||||
}
|
||||
|
||||
// Prompt 03 troca este ponto por cache/pool por tenant via TenantDataSourceProvider.
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.seconectai.condocontab.db;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class MigrationRunner {
|
||||
private MigrationRunner() {
|
||||
}
|
||||
|
||||
public static void runMigrations(Connection conn, String diretorioResources) throws SQLException, IOException {
|
||||
criarTabelaControle(conn);
|
||||
List<String> migrations = listarMigrations(diretorioResources);
|
||||
if (migrations.isEmpty()) {
|
||||
System.out.println("Nenhuma migration encontrada em " + diretorioResources + " (no-op)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (String migration : migrations) {
|
||||
if (jaExecutada(conn, migration)) {
|
||||
continue;
|
||||
}
|
||||
executarMigration(conn, diretorioResources, migration);
|
||||
registrarExecucao(conn, migration);
|
||||
System.out.println("Migration executada: " + migration);
|
||||
}
|
||||
}
|
||||
|
||||
private static void criarTabelaControle(Connection conn) throws SQLException {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome_arquivo VARCHAR(255) NOT NULL UNIQUE,
|
||||
executado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> listarMigrations(String diretorioResources) throws IOException {
|
||||
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
|
||||
URL url = classLoader.getResource(diretorioResources);
|
||||
if (url == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if ("file".equals(url.getProtocol())) {
|
||||
try {
|
||||
Path dir = Path.of(url.toURI());
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
return stream
|
||||
.filter(path -> path.getFileName().toString().endsWith(".sql"))
|
||||
.map(path -> path.getFileName().toString())
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Diretorio de migrations invalido: " + diretorioResources, e);
|
||||
}
|
||||
}
|
||||
|
||||
if ("jar".equals(url.getProtocol())) {
|
||||
return listarMigrationsNoJar(diretorioResources);
|
||||
}
|
||||
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> listarMigrationsNoJar(String diretorioResources) throws IOException {
|
||||
List<String> migrations = new ArrayList<>();
|
||||
String self = MigrationRunner.class.getProtectionDomain().getCodeSource().getLocation().getPath();
|
||||
URI jarUri = URI.create("jar:file:" + self);
|
||||
try (var fileSystem = java.nio.file.FileSystems.newFileSystem(jarUri, Collections.emptyMap())) {
|
||||
Path dir = fileSystem.getPath(diretorioResources);
|
||||
if (!Files.exists(dir)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
stream.filter(path -> path.getFileName().toString().endsWith(".sql"))
|
||||
.map(path -> path.getFileName().toString())
|
||||
.sorted()
|
||||
.forEach(migrations::add);
|
||||
}
|
||||
}
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private static boolean jaExecutada(Connection conn, String nomeArquivo) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT 1 FROM schema_migrations WHERE nome_arquivo = ?")) {
|
||||
ps.setString(1, nomeArquivo);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void executarMigration(Connection conn, String diretorioResources, String nomeArquivo)
|
||||
throws IOException, SQLException {
|
||||
String resource = diretorioResources + "/" + nomeArquivo;
|
||||
String sql = readResource(resource);
|
||||
boolean autoCommit = conn.getAutoCommit();
|
||||
conn.setAutoCommit(false);
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute(sql);
|
||||
conn.commit();
|
||||
} catch (SQLException e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(autoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readResource(String resource) throws IOException {
|
||||
InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
|
||||
if (input == null) {
|
||||
throw new IOException("Migration nao encontrada: " + resource);
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sql = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sql.append(line).append('\n');
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static void registrarExecucao(Connection conn, String nomeArquivo) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT INTO schema_migrations (nome_arquivo) VALUES (?)")) {
|
||||
ps.setString(1, nomeArquivo);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.seconectai.condocontab.financeiro;
|
||||
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class AbacatePayClient {
|
||||
private final AppConfig config;
|
||||
private final DataSourceProvider dataSourceProvider;
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
public AbacatePayClient(AppConfig config, DataSourceProvider dataSourceProvider) {
|
||||
this.config = config;
|
||||
this.dataSourceProvider = dataSourceProvider;
|
||||
}
|
||||
|
||||
public Cobranca criarCobranca(long valorCentavos, String descricao, String identificadorExterno) throws Exception {
|
||||
String apiKey = setting("abacatepay_api_key", config.abacatePayApiKey());
|
||||
if (apiKey.isBlank()) {
|
||||
String id = "mock_" + identificadorExterno;
|
||||
String payload = "000201 MOCK PIX " + valorCentavos + " " + identificadorExterno;
|
||||
String base64 = Base64.getEncoder().encodeToString(("QR:" + payload).getBytes(StandardCharsets.UTF_8));
|
||||
return new Cobranca(id, payload, base64);
|
||||
}
|
||||
|
||||
String json = "{\"amount\":" + valorCentavos
|
||||
+ ",\"description\":\"" + esc(descricao) + "\""
|
||||
+ ",\"expiresIn\":86400"
|
||||
+ ",\"metadata\":{\"externalId\":\"" + esc(identificadorExterno) + "\"}}";
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.abacatepay.com/v1/pixQrCode/create"))
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() < 200 || res.statusCode() >= 300) {
|
||||
throw new IllegalStateException("AbacatePay HTTP " + res.statusCode() + ": " + res.body());
|
||||
}
|
||||
String body = res.body();
|
||||
return new Cobranca(find(body, "id"), find(body, "brcode"), find(body, "brcode_base64"));
|
||||
}
|
||||
|
||||
public boolean validarWebhookSecret(String secret) {
|
||||
try {
|
||||
return setting("abacatepay_webhook_secret", config.abacatePayWebhookSecret()).equals(secret);
|
||||
} catch (Exception e) {
|
||||
return config.abacatePayWebhookSecret().equals(secret);
|
||||
}
|
||||
}
|
||||
|
||||
private String setting(String chave, String fallback) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT valor FROM configuracoes_plataforma WHERE chave=?")) {
|
||||
ps.setString(1, chave);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
String valor = rs.getString(1);
|
||||
if (valor != null && !valor.isBlank()) return valor;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
private String esc(String s) {
|
||||
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private String find(String json, String key) {
|
||||
String marker = "\"" + key + "\"";
|
||||
int p = json.indexOf(marker);
|
||||
if (p < 0) return null;
|
||||
int c = json.indexOf(':', p);
|
||||
int q = json.indexOf('"', c + 1);
|
||||
int e = json.indexOf('"', q + 1);
|
||||
return q < 0 || e < 0 ? null : json.substring(q + 1, e);
|
||||
}
|
||||
|
||||
public record Cobranca(String chargeId, String qrCodePayload, String qrCodeBase64) {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.seconectai.condocontab.financeiro;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;import com.seconectai.condocontab.http.HttpResponses;import com.seconectai.condocontab.util.SimpleJson;import com.sun.net.httpserver.*;import java.io.*;import java.nio.charset.StandardCharsets;import java.sql.*;import java.util.*;
|
||||
public final class AbacatePayWebhookHandler implements HttpHandler{private final DataSourceProvider ds;private final AbacatePayClient abacate;public AbacatePayWebhookHandler(DataSourceProvider ds,AbacatePayClient abacate){this.ds=ds;this.abacate=abacate;}public void handle(HttpExchange ex)throws IOException{try{String secret=query(ex).get("webhookSecret");if(!abacate.validarWebhookSecret(secret)){HttpResponses.json(ex,401,Map.of("erro","webhook_invalido"));return;}String body=new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);String charge=extractCharge(body);try(Connection c=ds.getMasterConnection()){Map<String,Object> p=one(c,"SELECT * FROM pagamentos WHERE abacatepay_charge_id=?",charge);if(p==null){HttpResponses.json(ex,404,Map.of("erro","pagamento_nao_encontrado"));return;}long pid=((Number)p.get("id")).longValue(), per=((Number)p.get("periodo_uso_id")).longValue(), emp=((Number)p.get("empresa_id")).longValue();try(PreparedStatement ps=c.prepareStatement("UPDATE pagamentos SET status='pago', pago_em=COALESCE(pago_em,now()) WHERE id=?")){ps.setLong(1,pid);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now() WHERE id=?")){ps.setLong(1,per);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")){ps.setLong(1,emp);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}}catch(Exception e){e.printStackTrace(System.err);HttpResponses.json(ex,500,Map.of("erro","erro_webhook"));}}private String extractCharge(String body){try{Map<String,String>m=SimpleJson.parseObject(body);for(String k:List.of("chargeId","id","abacatepay_charge_id"))if(m.get(k)!=null)return m.get(k);}catch(Exception ignored){}String[]keys={"id","chargeId"};for(String k:keys){String m="\""+k+"\"";int p=body.indexOf(m);if(p>=0){int c=body.indexOf(':',p),q=body.indexOf('"',c+1),e=body.indexOf('"',q+1);if(q>=0&&e>q)return body.substring(q+1,e);}}return null;}private Map<String,String>query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(kv[0],kv.length>1?kv[1]:"");}return q;}private Map<String,Object>one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.length;i++)ps.setObject(i+1,params[i]);try(ResultSet rs=ps.executeQuery()){if(!rs.next())return null;Map<String,Object>m=new HashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}}}}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.seconectai.condocontab.financeiro;
|
||||
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class BloqueioJob implements Runnable {
|
||||
private final DataSourceProvider ds;
|
||||
|
||||
public BloqueioJob(DataSourceProvider ds) {
|
||||
this.ds = ds;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
try {
|
||||
System.out.println("BloqueioJob bloqueou " + executarAgora() + " empresas");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
public int executarAgora() throws SQLException {
|
||||
int n = 0;
|
||||
try (Connection c = ds.getMasterConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status='ativa'")) {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
long emp = rs.getLong(1);
|
||||
long per = periodo(c, emp);
|
||||
PeriodoStatus st = status(c, per);
|
||||
if (LocalDate.now().isAfter(st.dataVencimento()) && !"pago".equals(st.status())) {
|
||||
try (PreparedStatement u = c.prepareStatement("UPDATE periodos_uso SET status='bloqueado', data_bloqueio=now() WHERE id=?; UPDATE empresas SET status='bloqueada' WHERE id=?")) {
|
||||
u.setLong(1, per);
|
||||
u.setLong(2, emp);
|
||||
u.execute();
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private long periodo(Connection c, long emp) throws SQLException {
|
||||
int dia = diaVencimento(c, emp);
|
||||
LocalDate venc = vencimentoAtual(dia);
|
||||
LocalDate inicio = venc.plusDays(1);
|
||||
LocalDate fim = venc.plusMonths(1);
|
||||
LocalDate mes = venc.withDayOfMonth(1);
|
||||
try (PreparedStatement ps = c.prepareStatement("INSERT INTO periodos_uso (empresa_id,mes_referencia,data_inicio,data_fim,data_vencimento,status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id,mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")) {
|
||||
ps.setLong(1, emp);
|
||||
ps.setDate(2, java.sql.Date.valueOf(mes));
|
||||
ps.setDate(3, java.sql.Date.valueOf(inicio));
|
||||
ps.setDate(4, java.sql.Date.valueOf(fim));
|
||||
ps.setDate(5, java.sql.Date.valueOf(venc));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate vencimentoAtual(int dia) {
|
||||
LocalDate hoje = LocalDate.now();
|
||||
LocalDate base = hoje.withDayOfMonth(Math.min(dia, hoje.lengthOfMonth()));
|
||||
if (hoje.isBefore(base)) {
|
||||
LocalDate anterior = hoje.minusMonths(1);
|
||||
base = anterior.withDayOfMonth(Math.min(dia, anterior.lengthOfMonth()));
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private int diaVencimento(Connection c, long emp) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1")) {
|
||||
ps.setLong(1, emp);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return 7;
|
||||
return Math.max(1, Math.min(28, rs.getInt(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PeriodoStatus status(Connection c, long id) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT status,data_vencimento FROM periodos_uso WHERE id=?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return new PeriodoStatus(rs.getString(1), rs.getDate(2).toLocalDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PeriodoStatus(String status, LocalDate dataVencimento) {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
package com.seconectai.condocontab.http;
|
||||
|
||||
import com.seconectai.condocontab.util.Json;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
public final class HttpResponses {
|
||||
private HttpResponses() {
|
||||
}
|
||||
|
||||
public static void json(HttpExchange exchange, int status, Map<String, Object> body) throws IOException {
|
||||
json(exchange, status, Json.write(body));
|
||||
}
|
||||
|
||||
public static void json(HttpExchange exchange, int status, String body) throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static void methodNotAllowed(HttpExchange exchange) throws IOException {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.seconectai.condocontab.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class Router implements HttpHandler {
|
||||
private final Map<String, HttpHandler> exactRoutes = new LinkedHashMap<>();
|
||||
private final Map<String, HttpHandler> prefixRoutes = new LinkedHashMap<>();
|
||||
private HttpHandler fallbackHandler;
|
||||
|
||||
public void get(String path, HttpHandler handler) {
|
||||
route("GET", path, handler);
|
||||
}
|
||||
|
||||
public void post(String path, HttpHandler handler) {
|
||||
route("POST", path, handler);
|
||||
}
|
||||
|
||||
public void route(String method, String path, HttpHandler handler) {
|
||||
exactRoutes.put(method.toUpperCase() + " " + path, handler);
|
||||
}
|
||||
|
||||
public void prefix(String prefix, HttpHandler handler) {
|
||||
prefixRoutes.put(prefix, handler);
|
||||
}
|
||||
|
||||
public void fallback(HttpHandler handler) {
|
||||
this.fallbackHandler = handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
String key = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
|
||||
HttpHandler exact = exactRoutes.get(key);
|
||||
if (exact != null) {
|
||||
exact.handle(exchange);
|
||||
return;
|
||||
}
|
||||
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
for (Map.Entry<String, HttpHandler> entry : prefixRoutes.entrySet()) {
|
||||
if (path.startsWith(entry.getKey())) {
|
||||
entry.getValue().handle(exchange);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackHandler != null) {
|
||||
fallbackHandler.handle(exchange);
|
||||
return;
|
||||
}
|
||||
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.seconectai.condocontab.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
public final class StaticFileHandler implements HttpHandler {
|
||||
private static final Map<String, String> CONTENT_TYPES = Map.of(
|
||||
"html", "text/html; charset=utf-8",
|
||||
"css", "text/css; charset=utf-8",
|
||||
"js", "application/javascript; charset=utf-8",
|
||||
"json", "application/json; charset=utf-8",
|
||||
"png", "image/png"
|
||||
);
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
if (!"GET".equals(exchange.getRequestMethod()) && !"HEAD".equals(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
String path = URLDecoder.decode(exchange.getRequestURI().getPath(), StandardCharsets.UTF_8);
|
||||
if (path.equals("/") || path.isBlank()) {
|
||||
path = "/index.html";
|
||||
}
|
||||
if (path.contains("..")) {
|
||||
exchange.sendResponseHeaders(400, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
String resourcePath = "webapp" + path;
|
||||
try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (input == null) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] body = input.readAllBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", contentType(path));
|
||||
if ("HEAD".equals(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
return;
|
||||
}
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream output = exchange.getResponseBody()) {
|
||||
output.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String contentType(String path) {
|
||||
int dot = path.lastIndexOf('.');
|
||||
if (dot < 0 || dot == path.length() - 1) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
return CONTENT_TYPES.getOrDefault(path.substring(dot + 1), "application/octet-stream");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.seconectai.condocontab.integracao;
|
||||
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.PasswordAuthentication;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
public final class EmailClient {
|
||||
public void enviar(Map<String, String> smtp, String destino, String assunto, String texto) throws MessagingException {
|
||||
if (smtp == null || smtp.getOrDefault("smtp_host", "").isBlank()) return;
|
||||
Properties props = new Properties();
|
||||
props.put("mail.smtp.host", smtp.get("smtp_host"));
|
||||
props.put("mail.smtp.port", smtp.getOrDefault("smtp_port", "587"));
|
||||
props.put("mail.smtp.auth", "true");
|
||||
props.put("mail.smtp.starttls.enable", smtp.getOrDefault("smtp_starttls", "true"));
|
||||
String user = smtp.getOrDefault("smtp_user", "");
|
||||
String pass = smtp.getOrDefault("smtp_password", "");
|
||||
Session session = Session.getInstance(props, new jakarta.mail.Authenticator() {
|
||||
@Override protected PasswordAuthentication getPasswordAuthentication() {
|
||||
return new PasswordAuthentication(user, pass);
|
||||
}
|
||||
});
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
message.setFrom(new InternetAddress(smtp.getOrDefault("smtp_from", user)));
|
||||
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(destino));
|
||||
message.setSubject(assunto == null ? "Aviso" : assunto, "UTF-8");
|
||||
message.setText(texto == null ? "" : texto, "UTF-8");
|
||||
Transport.send(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.seconectai.condocontab.integracao;
|
||||
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.util.Json;
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class EvolutionApiClient {
|
||||
private final String globalApiKey;
|
||||
private final String defaultBaseUrl;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public EvolutionApiClient(AppConfig config) {
|
||||
this.globalApiKey = config.evolutionGlobalApiKey();
|
||||
this.defaultBaseUrl = config.evolutionApiUrl();
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
}
|
||||
|
||||
public CriacaoInstancia criarInstancia(String baseUrl, String instanceName, String token, String numero) throws IOException, InterruptedException {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = defaultBaseUrl;
|
||||
}
|
||||
if (mockMode(baseUrl)) {
|
||||
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
|
||||
return new CriacaoInstancia(instanceName, token, qr, true);
|
||||
}
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("instanceName", instanceName);
|
||||
payload.put("integration", "WHATSAPP-BAILEYS");
|
||||
payload.put("qrcode", true);
|
||||
payload.put("number", onlyDigits(numero));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/instance/create"))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", globalApiKey)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
|
||||
.build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
|
||||
}
|
||||
return new CriacaoInstancia(instanceName, firstNonBlank(extractJsonString(resp.body(), "hash"), token), qrBase64(resp.body()), false);
|
||||
}
|
||||
|
||||
|
||||
public Map<String, Object> conectar(String baseUrl, String instanceName, String numero) throws IOException, InterruptedException {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = defaultBaseUrl;
|
||||
}
|
||||
if (mockMode(baseUrl)) {
|
||||
String qr = Base64.getEncoder().encodeToString(("QR:" + instanceName).getBytes(StandardCharsets.UTF_8));
|
||||
return Map.of("instance_name", instanceName, "qrcode_base64", qr, "mock", true);
|
||||
}
|
||||
String url = clean(baseUrl) + "/instance/connect/" + instanceName;
|
||||
String digits = onlyDigits(numero);
|
||||
if (!digits.isBlank()) {
|
||||
url += "?number=" + digits;
|
||||
}
|
||||
HttpResponse<String> resp = sendNoBody("GET", url, globalApiKey);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("instance_name", instanceName);
|
||||
out.put("pairing_code", extractJsonString(resp.body(), "pairingCode"));
|
||||
out.put("qr_code_payload", extractJsonString(resp.body(), "code"));
|
||||
out.put("qr_code_base64", qrBase64(resp.body()));
|
||||
out.put("raw", resp.body());
|
||||
return out;
|
||||
}
|
||||
|
||||
public Map<String, Object> logout(String baseUrl, String instanceName) throws IOException, InterruptedException {
|
||||
return instanceAction("DELETE", baseUrl, "/instance/logout/" + instanceName, instanceName);
|
||||
}
|
||||
|
||||
public Map<String, Object> excluirInstancia(String baseUrl, String instanceName) throws IOException, InterruptedException {
|
||||
return instanceAction("DELETE", baseUrl, "/instance/delete/" + instanceName, instanceName);
|
||||
}
|
||||
|
||||
public Map<String, Object> reiniciar(String baseUrl, String instanceName) throws IOException, InterruptedException {
|
||||
return instanceAction("POST", baseUrl, "/instance/restart/" + instanceName, instanceName);
|
||||
}
|
||||
|
||||
public Map<String, Object> estado(String baseUrl, String instanceName) throws IOException, InterruptedException {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = defaultBaseUrl;
|
||||
}
|
||||
if (mockMode(baseUrl)) {
|
||||
return Map.of("instance_name", instanceName, "state", "mock");
|
||||
}
|
||||
HttpResponse<String> resp = sendNoBody("GET", clean(baseUrl) + "/instance/connectionState/" + instanceName, globalApiKey);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("instance_name", instanceName);
|
||||
out.put("state", firstNonBlank(extractJsonString(resp.body(), "state"), extractJsonString(resp.body(), "connectionStatus")));
|
||||
out.put("raw", resp.body());
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> instanceAction(String method, String baseUrl, String path, String instanceName) throws IOException, InterruptedException {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = defaultBaseUrl;
|
||||
}
|
||||
if (mockMode(baseUrl)) {
|
||||
return Map.of("instance_name", instanceName, "ok", true, "mock", true);
|
||||
}
|
||||
HttpResponse<String> resp = sendNoBody(method, clean(baseUrl) + path, globalApiKey);
|
||||
return Map.of("instance_name", instanceName, "ok", true, "raw", resp.body());
|
||||
}
|
||||
|
||||
private HttpResponse<String> sendNoBody(String method, String url, String apiKey) throws IOException, InterruptedException {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("apikey", apiKey);
|
||||
if ("GET".equals(method)) {
|
||||
builder.GET();
|
||||
} else if ("DELETE".equals(method)) {
|
||||
builder.DELETE();
|
||||
} else {
|
||||
builder.method(method, HttpRequest.BodyPublishers.noBody());
|
||||
}
|
||||
HttpResponse<String> resp = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
public String enviarTexto(String baseUrl, String instanceName, String token, String numero, String texto) throws IOException, InterruptedException {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = defaultBaseUrl;
|
||||
}
|
||||
if (mockMode(baseUrl)) return "mock";
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("number", onlyDigits(numero));
|
||||
payload.put("text", texto);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(clean(baseUrl) + "/message/sendText/" + instanceName))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", globalApiKey)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(Json.write(payload)))
|
||||
.build();
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
|
||||
}
|
||||
return resp.body();
|
||||
}
|
||||
|
||||
private boolean mockMode(String baseUrl) {
|
||||
return globalApiKey == null || globalApiKey.isBlank() || baseUrl == null || baseUrl.isBlank() || baseUrl.startsWith("mock://");
|
||||
}
|
||||
|
||||
private String clean(String baseUrl) {
|
||||
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
}
|
||||
|
||||
public static String onlyDigits(String raw) {
|
||||
return raw == null ? "" : raw.replaceAll("\\D", "");
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String fallback) {
|
||||
return first == null || first.isBlank() ? fallback : first;
|
||||
}
|
||||
|
||||
private String qrBase64(String responseBody) throws IOException {
|
||||
String image = firstNonBlank(extractJsonString(responseBody, "base64"), extractJsonString(responseBody, "qrcode"));
|
||||
if (looksLikeImageBase64(image)) {
|
||||
return stripDataImagePrefix(image);
|
||||
}
|
||||
String payload = extractJsonString(responseBody, "code");
|
||||
if (payload == null || payload.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var matrix = new QRCodeWriter().encode(payload, BarcodeFormat.QR_CODE, 320, 320);
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
MatrixToImageWriter.writeToStream(matrix, "PNG", out);
|
||||
return Base64.getEncoder().encodeToString(out.toByteArray());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Falha ao gerar QR Code local", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeImageBase64(String value) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
String clean = stripDataImagePrefix(value);
|
||||
return clean.startsWith("iVBOR") || clean.startsWith("/9j/") || clean.startsWith("PHN2Zy");
|
||||
}
|
||||
|
||||
private String stripDataImagePrefix(String value) {
|
||||
if (value == null) return null;
|
||||
int comma = value.indexOf(',');
|
||||
return value.startsWith("data:image/") && comma > 0 ? value.substring(comma + 1) : value;
|
||||
}
|
||||
|
||||
private String extractJsonString(String raw, String key) {
|
||||
String marker = "\"" + key + "\"";
|
||||
int p = raw.indexOf(marker);
|
||||
if (p < 0) return null;
|
||||
int colon = raw.indexOf(':', p);
|
||||
int first = raw.indexOf('"', colon + 1);
|
||||
int second = raw.indexOf('"', first + 1);
|
||||
if (first < 0 || second < 0) return null;
|
||||
return raw.substring(first + 1, second);
|
||||
}
|
||||
|
||||
public record CriacaoInstancia(String instanceName, String token, String qrcodeBase64, boolean mock) {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.seconectai.condocontab.master.dao;
|
||||
|
||||
import com.seconectai.condocontab.master.model.Empresa;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class EmpresaDao {
|
||||
private final Connection conn;
|
||||
|
||||
public EmpresaDao(Connection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
public Empresa inserir(Empresa empresa) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO empresas (nome, cnpj, slug, database_name, status)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
ps.setString(1, empresa.nome());
|
||||
ps.setString(2, empresa.cnpj());
|
||||
ps.setString(3, empresa.slug());
|
||||
ps.setString(4, empresa.databaseName());
|
||||
ps.setString(5, empresa.status() == null ? "ativa" : empresa.status());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) {
|
||||
throw new SQLException("Falha ao obter id gerado para empresa");
|
||||
}
|
||||
return buscarPorId(keys.getLong(1)).orElseThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Empresa> buscarPorId(long id) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas WHERE id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<Empresa> listarTodas() throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM empresas ORDER BY id")) {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<Empresa> empresas = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
empresas.add(map(rs));
|
||||
}
|
||||
return empresas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void atualizarStatus(long id, String status) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status = ? WHERE id = ?")) {
|
||||
ps.setString(1, status);
|
||||
ps.setLong(2, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private Empresa map(ResultSet rs) throws SQLException {
|
||||
return new Empresa(
|
||||
rs.getLong("id"),
|
||||
rs.getString("nome"),
|
||||
rs.getString("cnpj"),
|
||||
rs.getString("slug"),
|
||||
rs.getString("database_name"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("criado_em").toLocalDateTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.seconectai.condocontab.master.dao;
|
||||
|
||||
import com.seconectai.condocontab.master.model.Funcionalidade;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class FuncionalidadeDao {
|
||||
private final Connection conn;
|
||||
|
||||
public FuncionalidadeDao(Connection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
public List<Funcionalidade> listarTodas() throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades ORDER BY chave")) {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<Funcionalidade> funcionalidades = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
funcionalidades.add(map(rs));
|
||||
}
|
||||
return funcionalidades;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Funcionalidade> buscarPorChave(String chave) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionalidades WHERE chave = ?")) {
|
||||
ps.setString(1, chave);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Funcionalidade map(ResultSet rs) throws SQLException {
|
||||
return new Funcionalidade(
|
||||
rs.getLong("id"),
|
||||
rs.getString("chave"),
|
||||
rs.getString("nome"),
|
||||
rs.getString("descricao")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.seconectai.condocontab.master.dao;
|
||||
|
||||
import com.seconectai.condocontab.master.model.Funcionario;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class FuncionarioDao {
|
||||
private final Connection conn;
|
||||
|
||||
public FuncionarioDao(Connection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
public Funcionario inserir(Funcionario funcionario) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO funcionarios (empresa_id, nome, email, telefone, senha_hash, cargo, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
ps.setLong(1, funcionario.empresaId());
|
||||
ps.setString(2, funcionario.nome());
|
||||
ps.setString(3, funcionario.email());
|
||||
ps.setString(4, funcionario.telefone());
|
||||
ps.setString(5, funcionario.senhaHash());
|
||||
ps.setString(6, funcionario.cargo());
|
||||
ps.setString(7, funcionario.status() == null ? "ativo" : funcionario.status());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) {
|
||||
throw new SQLException("Falha ao obter id gerado para funcionario");
|
||||
}
|
||||
return buscarPorId(keys.getLong(1)).orElseThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Funcionario> buscarPorEmail(String email) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE email = ?")) {
|
||||
ps.setString(1, email);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<Funcionario> listarPorEmpresa(long empresaId) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE empresa_id = ? ORDER BY id")) {
|
||||
ps.setLong(1, empresaId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<Funcionario> funcionarios = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
funcionarios.add(map(rs));
|
||||
}
|
||||
return funcionarios;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Funcionario> buscarPorId(long id) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM funcionarios WHERE id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Funcionario map(ResultSet rs) throws SQLException {
|
||||
return new Funcionario(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("empresa_id"),
|
||||
rs.getString("nome"),
|
||||
rs.getString("email"),
|
||||
rs.getString("telefone"),
|
||||
rs.getString("senha_hash"),
|
||||
rs.getString("cargo"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("criado_em").toLocalDateTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package com.seconectai.condocontab.master.http;
|
||||
|
||||
import com.seconectai.condocontab.auth.PasswordHasher;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
import com.seconectai.condocontab.http.HttpResponses;
|
||||
import com.seconectai.condocontab.integracao.EvolutionApiClient;
|
||||
import com.seconectai.condocontab.master.dao.EmpresaDao;
|
||||
import com.seconectai.condocontab.master.dao.FuncionarioDao;
|
||||
import com.seconectai.condocontab.master.model.Empresa;
|
||||
import com.seconectai.condocontab.master.model.Funcionario;
|
||||
import com.seconectai.condocontab.tenant.provisioning.TenantProvisioner;
|
||||
import com.seconectai.condocontab.util.SimpleJson;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
public final class MasterHandlers implements HttpHandler {
|
||||
private final DataSourceProvider dataSourceProvider;
|
||||
private final TenantProvisioner tenantProvisioner;
|
||||
private final EvolutionApiClient evolutionApiClient;
|
||||
|
||||
public MasterHandlers(DataSourceProvider dataSourceProvider, TenantProvisioner tenantProvisioner, EvolutionApiClient evolutionApiClient) {
|
||||
this.dataSourceProvider = dataSourceProvider;
|
||||
this.tenantProvisioner = tenantProvisioner;
|
||||
this.evolutionApiClient = evolutionApiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
route(exchange);
|
||||
} catch (IllegalArgumentException e) {
|
||||
HttpResponses.json(exchange, 400, Map.of("erro", "requisicao_invalida", "mensagem", e.getMessage()));
|
||||
} catch (SQLException e) {
|
||||
HttpResponses.json(exchange, 500, Map.of("erro", "erro_banco", "mensagem", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
HttpResponses.json(exchange, 500, Map.of("erro", "erro_interno", "mensagem", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private void route(HttpExchange ex) throws Exception {
|
||||
String method = ex.getRequestMethod();
|
||||
String path = ex.getRequestURI().getPath();
|
||||
String rest = path.substring("/api/master".length());
|
||||
if (rest.isEmpty()) rest = "/";
|
||||
String[] parts = Arrays.stream(rest.split("/"))
|
||||
.filter(s -> !s.isBlank())
|
||||
.map(s -> URLDecoder.decode(s, StandardCharsets.UTF_8))
|
||||
.toArray(String[]::new);
|
||||
|
||||
if (parts.length == 1 && "empresas".equals(parts[0])) {
|
||||
if ("POST".equals(method)) criarEmpresa(ex);
|
||||
else if ("GET".equals(method)) listarEmpresas(ex);
|
||||
else HttpResponses.methodNotAllowed(ex);
|
||||
return;
|
||||
}
|
||||
if (parts.length == 2 && "empresas".equals(parts[0])) {
|
||||
long id = parseId(parts[1]);
|
||||
if ("GET".equals(method)) obterEmpresa(ex, id);
|
||||
else if ("PUT".equals(method)) atualizarEmpresa(ex, id);
|
||||
else if ("DELETE".equals(method)) arquivarEmpresa(ex, id);
|
||||
else HttpResponses.methodNotAllowed(ex);
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionarios".equals(parts[2])) {
|
||||
long id = parseId(parts[1]);
|
||||
if ("POST".equals(method)) criarFuncionario(ex, id);
|
||||
else if ("GET".equals(method)) listarFuncionarios(ex, id);
|
||||
else HttpResponses.methodNotAllowed(ex);
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "GET".equals(method)) {
|
||||
listarFuncionalidadesEmpresa(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 5 && "empresas".equals(parts[0]) && "funcionalidades".equals(parts[2]) && "POST".equals(method)) {
|
||||
alterarFuncionalidadeEmpresa(ex, parseId(parts[1]), parts[3], "ativar".equals(parts[4]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "lembrete-cobranca".equals(parts[2]) && "POST".equals(method)) {
|
||||
registrarLembrete(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "vencimento".equals(parts[2]) && "PUT".equals(method)) {
|
||||
atualizarVencimentoEmpresa(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "status".equals(parts[2]) && "POST".equals(method)) {
|
||||
alterarStatusEmpresa(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "desbloquear".equals(parts[2]) && "POST".equals(method)) {
|
||||
desbloquearEmpresa(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 3 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "GET".equals(method)) {
|
||||
listarEvolutionInstancias(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 5 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "POST".equals(method)) {
|
||||
alterarEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]), "aprovar".equals(parts[4]) ? "aprovada" : "rejeitada");
|
||||
return;
|
||||
}
|
||||
if (parts.length == 4 && "empresas".equals(parts[0]) && "evolution-instancias".equals(parts[2]) && "DELETE".equals(method)) {
|
||||
excluirEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 1 && "configuracoes".equals(parts[0])) {
|
||||
if ("GET".equals(method)) listarConfiguracoes(ex);
|
||||
else if ("PUT".equals(method)) salvarConfiguracoes(ex);
|
||||
else HttpResponses.methodNotAllowed(ex);
|
||||
return;
|
||||
}
|
||||
if (parts.length == 1 && "planos".equals(parts[0])) {
|
||||
if ("GET".equals(method)) listarPlanos(ex);
|
||||
else if ("POST".equals(method)) criarPlano(ex);
|
||||
else HttpResponses.methodNotAllowed(ex);
|
||||
return;
|
||||
}
|
||||
if (parts.length == 2 && "planos".equals(parts[0]) && "PUT".equals(method)) {
|
||||
atualizarPlano(ex, parseId(parts[1]));
|
||||
return;
|
||||
}
|
||||
if (parts.length == 1 && "logs-envio".equals(parts[0]) && "GET".equals(method)) {
|
||||
listarLogsEnvio(ex);
|
||||
return;
|
||||
}
|
||||
HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado"));
|
||||
}
|
||||
|
||||
private void listarEvolutionInstancias(HttpExchange ex, long empresaId) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, numero_whatsapp, status, solicitado_em, aprovado_em, aprovado_por FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC, id DESC")) {
|
||||
ps.setLong(1, empresaId);
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(resultMap(rs)); }
|
||||
HttpResponses.json(ex, 200, Map.of("instancias", rows));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void excluirEvolutionInstancia(HttpExchange ex, long empresaId, long instId) throws Exception {
|
||||
Map<String,Object> inst;
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, instance_token_enc, numero_whatsapp FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
|
||||
ps.setLong(1, instId);
|
||||
ps.setLong(2, empresaId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada"));
|
||||
return;
|
||||
}
|
||||
inst = resultMap(rs);
|
||||
}
|
||||
}
|
||||
String instanceToken = String.valueOf(inst.get("instance_token_enc"));
|
||||
String instanceName = instanceToken.split(":", 2)[0];
|
||||
String evolutionErro = null;
|
||||
try {
|
||||
evolutionApiClient.excluirInstancia(String.valueOf(inst.get("instance_url")), instanceName);
|
||||
} catch (Exception err) {
|
||||
evolutionErro = err.getMessage();
|
||||
String msg = evolutionErro == null ? "" : evolutionErro.toLowerCase(Locale.ROOT);
|
||||
if (!(msg.contains("404") || msg.contains("not found") || msg.contains("não encontrada") || msg.contains("nao encontrada") || msg.contains("instance not found"))) {
|
||||
HttpResponses.json(ex, 502, Map.of("erro", "falha_excluir_evolution", "mensagem", evolutionErro));
|
||||
return;
|
||||
}
|
||||
}
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("DELETE FROM evolution_instancias WHERE id=? AND empresa_id=?")) {
|
||||
ps.setLong(1, instId);
|
||||
ps.setLong(2, empresaId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
Map<String,Object> resp = new LinkedHashMap<>();
|
||||
resp.put("ok", true);
|
||||
resp.put("evolution_instance", instanceName);
|
||||
if (evolutionErro != null) resp.put("aviso", "Registro local removido; a Evolution informou que a instancia ja nao existia: " + evolutionErro);
|
||||
HttpResponses.json(ex, 200, resp);
|
||||
}
|
||||
|
||||
private void alterarEvolutionInstancia(HttpExchange ex, long empresaId, long instId, String status) throws Exception {
|
||||
if (!"aprovada".equals(status) && !"rejeitada".equals(status)) { HttpResponses.json(ex, 400, Map.of("erro", "acao_invalida")); return; }
|
||||
String sql = "UPDATE evolution_instancias SET status=?, aprovado_em=CASE WHEN ?='aprovada' THEN now() ELSE aprovado_em END, aprovado_por=NULL WHERE id=? AND empresa_id=?";
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
conn.setAutoCommit(false);
|
||||
ps.setString(1, status); ps.setString(2, status); ps.setLong(3, instId); ps.setLong(4, empresaId);
|
||||
if (ps.executeUpdate() == 0) { conn.rollback(); HttpResponses.json(ex, 404, Map.of("erro", "instancia_nao_encontrada")); return; }
|
||||
if ("aprovada".equals(status)) ativarFuncionalidade(conn, empresaId, "whatsapp_envio");
|
||||
conn.commit();
|
||||
listarEvolutionInstancias(ex, empresaId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ativarFuncionalidade(Connection conn, long empresaId, String chave) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
|
||||
SELECT ?, id, true, now(), NULL FROM funcionalidades WHERE chave=?
|
||||
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=true, liberado_em=now()
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, empresaId);
|
||||
ps.setString(2, chave);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void listarConfiguracoes(HttpExchange ex) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT chave, valor, atualizado_em FROM configuracoes_plataforma ORDER BY chave")) {
|
||||
Map<String,Object> cfg = new LinkedHashMap<>();
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String chave = rs.getString("chave");
|
||||
Object valor = Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) ? "********" : rs.getString("valor");
|
||||
cfg.put(chave, valor);
|
||||
Map<String,Object> row = new LinkedHashMap<>(); row.put("chave", chave); row.put("valor", valor); row.put("atualizado_em", rs.getObject("atualizado_em")); rows.add(row);
|
||||
}
|
||||
}
|
||||
HttpResponses.json(ex, 200, Map.of("configuracoes", cfg, "itens", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private void salvarConfiguracoes(HttpExchange ex) throws Exception {
|
||||
Map<String,String> b = body(ex);
|
||||
Set<String> permitidas = Set.of("smtp_host","smtp_port","smtp_user","smtp_password","smtp_from","smtp_starttls","abacatepay_api_key","abacatepay_webhook_secret");
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO configuracoes_plataforma (chave, valor, atualizado_em) VALUES (?, ?, now()) ON CONFLICT (chave) DO UPDATE SET valor=EXCLUDED.valor, atualizado_em=now()")) {
|
||||
for (String chave : permitidas) {
|
||||
if (!b.containsKey(chave)) continue;
|
||||
String valor = b.get(chave);
|
||||
if (Set.of("smtp_password", "abacatepay_api_key", "abacatepay_webhook_secret").contains(chave) && (valor == null || valor.isBlank() || "********".equals(valor))) continue;
|
||||
ps.setString(1, chave); ps.setString(2, valor); ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
}
|
||||
listarConfiguracoes(ex);
|
||||
}
|
||||
|
||||
private void criarEmpresa(HttpExchange ex) throws Exception {
|
||||
Map<String, String> body = body(ex);
|
||||
String nome = required(body, "nome");
|
||||
String cnpj = body.get("cnpj");
|
||||
String slug = required(body, "slug");
|
||||
Long planoId = optionalLong(body.get("plano_id"));
|
||||
String emailPrincipal = required(body, "email");
|
||||
String senhaPrincipal = required(body, "senha");
|
||||
String telefonePrincipal = body.get("telefone");
|
||||
String dbName = tenantProvisioner.databaseNameFromSlug(slug);
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
if (exists(conn, "SELECT 1 FROM empresas WHERE slug = ?", slug.replace('-', '_'))) {
|
||||
HttpResponses.json(ex, 409, Map.of("erro", "slug_duplicado"));
|
||||
return;
|
||||
}
|
||||
if (exists(conn, "SELECT 1 FROM funcionarios WHERE email = ?", emailPrincipal)) {
|
||||
HttpResponses.json(ex, 409, Map.of("erro", "email_duplicado", "mensagem", "Este e-mail ja esta cadastrado como login."));
|
||||
return;
|
||||
}
|
||||
EmpresaDao dao = new EmpresaDao(conn);
|
||||
Empresa criada = dao.inserir(new Empresa(null, nome, cnpj, slug.replace('-', '_'), dbName, "provisionando", null));
|
||||
var resultado = tenantProvisioner.provisionar(nome, slug);
|
||||
String status = resultado.sucesso() ? "ativa" : "provisionamento_pendente";
|
||||
dao.atualizarStatus(criada.id(), status);
|
||||
if (planoId != null) { salvarAssinatura(conn, criada.id(), planoId); copiarFuncionalidadesPlano(conn, criada.id(), planoId); }
|
||||
Funcionario principal = new FuncionarioDao(conn).inserir(new Funcionario(null, criada.id(), nome, emailPrincipal, telefonePrincipal, PasswordHasher.hash(senhaPrincipal), "admin_empresa", "ativo", null));
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("empresa", empresaMap(dao.buscarPorId(criada.id()).orElseThrow()));
|
||||
resp.put("funcionario_principal", funcionarioMap(principal));
|
||||
resp.put("provisionamento", resultado.sucesso());
|
||||
resp.put("erro", resultado.erro());
|
||||
HttpResponses.json(ex, 201, resp);
|
||||
}
|
||||
}
|
||||
|
||||
private void listarEmpresas(HttpExchange ex) throws Exception {
|
||||
Map<String, String> q = query(ex);
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT e.*, p.id AS plano_id, p.nome AS plano_nome, p.valor_mensal_centavos AS plano_valor_centavos, a.dia_vencimento, pu.status AS periodo_status, pu.mes_referencia, pu.data_inicio, pu.data_fim, pu.data_vencimento
|
||||
FROM empresas e
|
||||
LEFT JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa'
|
||||
LEFT JOIN planos p ON p.id=a.plano_id
|
||||
LEFT JOIN LATERAL (SELECT * FROM periodos_uso pu WHERE pu.empresa_id=e.id ORDER BY pu.mes_referencia DESC LIMIT 1) pu ON true
|
||||
WHERE 1=1
|
||||
""");
|
||||
List<Object> params = new ArrayList<>();
|
||||
if (q.containsKey("status")) { sql.append(" AND e.status=?"); params.add(q.get("status")); } else { sql.append(" AND e.status<>'excluida'"); }
|
||||
if ("pagante".equals(q.get("situacao"))) { sql.append(" AND pu.status='pago'"); }
|
||||
sql.append(" ORDER BY e.id");
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
garantirPeriodosAtuais(conn);
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql.toString())) {
|
||||
bind(ps, params);
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(empresaResumoMap(rs)); }
|
||||
HttpResponses.json(ex, 200, Map.of("empresas", rows));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void obterEmpresa(HttpExchange ex, long id) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
var emp = new EmpresaDao(conn).buscarPorId(id);
|
||||
if (emp.isEmpty()) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
|
||||
Map<String,Object> out = empresaMap(emp.get());
|
||||
Map<String,Object> plano = one(conn, "SELECT p.id,p.nome,p.valor_mensal_centavos FROM assinaturas a JOIN planos p ON p.id=a.plano_id WHERE a.empresa_id=? AND a.status='ativa' ORDER BY a.id DESC LIMIT 1", List.of(id));
|
||||
out.put("plano", plano);
|
||||
HttpResponses.json(ex, 200, Map.of("empresa", out));
|
||||
}
|
||||
}
|
||||
|
||||
private void atualizarEmpresa(HttpExchange ex, long id) throws Exception {
|
||||
Map<String,String> b = body(ex);
|
||||
Long planoId = optionalLong(b.get("plano_id"));
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
conn.setAutoCommit(false);
|
||||
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj) WHERE id=?")) {
|
||||
ps.setString(1, emptyToNull(b.get("nome"))); ps.setString(2, emptyToNull(b.get("cnpj"))); ps.setLong(3, id); ps.executeUpdate();
|
||||
}
|
||||
if (planoId != null) { salvarAssinatura(conn, id, planoId); copiarFuncionalidadesPlano(conn, id, planoId); }
|
||||
conn.commit();
|
||||
obterEmpresa(ex, id);
|
||||
}
|
||||
}
|
||||
|
||||
private void arquivarEmpresa(HttpExchange ex, long id) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='excluida' WHERE id=?")) {
|
||||
ps.setLong(1, id);
|
||||
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
|
||||
HttpResponses.json(ex, 200, Map.of("ok", true, "status", "excluida"));
|
||||
}
|
||||
}
|
||||
|
||||
private void criarFuncionario(HttpExchange ex, long empresaId) throws Exception {
|
||||
Map<String,String> b = body(ex);
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
Funcionario f = new FuncionarioDao(conn).inserir(new Funcionario(null, empresaId, required(b,"nome"), required(b,"email"), b.get("telefone"), PasswordHasher.hash(required(b,"senha")), b.get("cargo"), "ativo", null));
|
||||
HttpResponses.json(ex, 201, Map.of("funcionario", funcionarioMap(f)));
|
||||
}
|
||||
}
|
||||
|
||||
private void listarFuncionarios(HttpExchange ex, long empresaId) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
for (Funcionario f : new FuncionarioDao(conn).listarPorEmpresa(empresaId)) rows.add(funcionarioMap(f));
|
||||
HttpResponses.json(ex, 200, Map.of("funcionarios", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private void listarFuncionalidadesEmpresa(HttpExchange ex, long empresaId) throws Exception {
|
||||
String sql = """
|
||||
SELECT f.chave, f.nome, f.descricao, COALESCE(ef.ativa,false) AS ativa
|
||||
FROM funcionalidades f
|
||||
LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=?
|
||||
ORDER BY f.chave
|
||||
""";
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, empresaId);
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(Map.of("chave", rs.getString("chave"), "nome", rs.getString("nome"), "descricao", rs.getString("descricao"), "ativa", rs.getBoolean("ativa"))); }
|
||||
HttpResponses.json(ex, 200, Map.of("funcionalidades", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private void alterarFuncionalidadeEmpresa(HttpExchange ex, long empresaId, String chave, boolean ativa) throws Exception {
|
||||
String sql = """
|
||||
INSERT INTO empresa_funcionalidades (empresa_id, funcionalidade_id, ativa, liberado_em, liberado_por)
|
||||
SELECT ?, id, ?, now(), NULL FROM funcionalidades WHERE chave=?
|
||||
ON CONFLICT (empresa_id, funcionalidade_id) DO UPDATE SET ativa=EXCLUDED.ativa, liberado_em=now()
|
||||
""";
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, empresaId); ps.setBoolean(2, ativa); ps.setString(3, chave);
|
||||
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "funcionalidade_nao_encontrada")); return; }
|
||||
listarFuncionalidadesEmpresa(ex, empresaId);
|
||||
}
|
||||
}
|
||||
|
||||
private void criarPlano(HttpExchange ex) throws Exception { salvarPlano(ex, null); }
|
||||
private void atualizarPlano(HttpExchange ex, long id) throws Exception { salvarPlano(ex, id); }
|
||||
|
||||
private void salvarPlano(HttpExchange ex, Long id) throws Exception {
|
||||
String raw = rawBody(ex);
|
||||
List<String> funcionalidades = stringArray(raw, "funcionalidades");
|
||||
Map<String,String> b = SimpleJson.parseObject(removeArrayField(raw, "funcionalidades"));
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
conn.setAutoCommit(false);
|
||||
long planoId = id == null ? inserirPlano(conn, b) : atualizarPlanoRow(conn, id, b);
|
||||
sincronizarPlanoFuncionalidades(conn, planoId, funcionalidades);
|
||||
conn.commit();
|
||||
HttpResponses.json(ex, id == null ? 201 : 200, Map.of("plano", planoMap(conn, planoId)));
|
||||
}
|
||||
}
|
||||
|
||||
private void listarPlanos(HttpExchange ex) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM planos ORDER BY id")) {
|
||||
List<Map<String,Object>> rows = new ArrayList<>();
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(planoMap(rs)); }
|
||||
HttpResponses.json(ex, 200, Map.of("planos", rows));
|
||||
}
|
||||
}
|
||||
|
||||
private void listarLogsEnvio(HttpExchange ex) throws Exception {
|
||||
Map<String,String> q=query(ex); int limit=(int)(optionalLong(q.get("limit"))==null?50:optionalLong(q.get("limit"))); int offset=(int)(optionalLong(q.get("offset"))==null?0:optionalLong(q.get("offset")));
|
||||
StringBuilder sql=new StringBuilder("SELECT * FROM logs_envio_avisos WHERE 1=1"); List<Object> params=new ArrayList<>();
|
||||
if(q.get("status")!=null){sql.append(" AND status=?");params.add(q.get("status"));}
|
||||
if(q.get("empresa_id")!=null){sql.append(" AND empresa_id=?");params.add(Long.parseLong(q.get("empresa_id")));}
|
||||
sql.append(" ORDER BY criado_em DESC LIMIT ? OFFSET ?"); params.add(limit); params.add(offset);
|
||||
try(Connection conn=dataSourceProvider.getMasterConnection(); PreparedStatement ps=conn.prepareStatement(sql.toString())){ bind(ps,params); List<Map<String,Object>> rows=new ArrayList<>(); try(ResultSet rs=ps.executeQuery()){while(rs.next()) rows.add(resultMap(rs));} HttpResponses.json(ex,200,Map.of("logs",rows));}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void atualizarVencimentoEmpresa(HttpExchange ex,long empresaId)throws Exception{
|
||||
Map<String,String>b=body(ex);
|
||||
int dia=Integer.parseInt(required(b,"dia_vencimento"));
|
||||
if(dia<1||dia>28){HttpResponses.json(ex,400,Map.of("erro","dia_vencimento_invalido","mensagem","Informe um dia entre 1 e 28."));return;}
|
||||
try(Connection conn=dataSourceProvider.getMasterConnection()){
|
||||
conn.setAutoCommit(false);
|
||||
try{
|
||||
if(!exists(conn,"SELECT 1 FROM empresas WHERE id=?",empresaId)){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","empresa_nao_encontrada"));return;}
|
||||
Map<String,Object>a=one(conn,"SELECT id FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));
|
||||
if(a==null){conn.rollback();HttpResponses.json(ex,404,Map.of("erro","assinatura_nao_encontrada","mensagem","Empresa sem assinatura ativa."));return;}
|
||||
try(PreparedStatement ps=conn.prepareStatement("UPDATE assinaturas SET dia_vencimento=? WHERE id=?")){ps.setInt(1,dia);ps.setLong(2,((Number)a.get("id")).longValue());ps.executeUpdate();}
|
||||
long periodo=periodoAtual(conn,empresaId);
|
||||
conn.commit();
|
||||
HttpResponses.json(ex,200,Map.of("ok",true,"empresa_id",empresaId,"dia_vencimento",dia,"periodo_uso_id",periodo));
|
||||
}catch(Exception err){conn.rollback();throw err;}finally{conn.setAutoCommit(true);}
|
||||
}
|
||||
}
|
||||
|
||||
private void desbloquearEmpresa(HttpExchange ex, long empresaId) throws Exception {
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection()) {
|
||||
conn.setAutoCommit(false);
|
||||
try {
|
||||
if (!exists(conn, "SELECT 1 FROM empresas WHERE id = ?", empresaId)) {
|
||||
conn.rollback();
|
||||
HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada"));
|
||||
return;
|
||||
}
|
||||
long periodoId = periodoAtual(conn, empresaId);
|
||||
try (PreparedStatement ps = conn.prepareStatement("UPDATE periodos_uso SET status='pago', data_liberacao=now(), data_bloqueio=NULL WHERE id=?")) {
|
||||
ps.setLong(1, periodoId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status='ativa' WHERE id=?")) {
|
||||
ps.setLong(1, empresaId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
conn.commit();
|
||||
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "periodo_uso_id", periodoId, "status", "ativa", "liberacao", "manual"));
|
||||
} catch (Exception e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void alterarStatusEmpresa(HttpExchange ex, long empresaId) throws Exception {
|
||||
Map<String,String> b = body(ex);
|
||||
String status = required(b, "status");
|
||||
if (!Set.of("ativa", "bloqueada").contains(status)) {
|
||||
HttpResponses.json(ex, 400, Map.of("erro", "status_invalido"));
|
||||
return;
|
||||
}
|
||||
try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("UPDATE empresas SET status=? WHERE id=? AND status<>'excluida'")) {
|
||||
ps.setString(1, status);
|
||||
ps.setLong(2, empresaId);
|
||||
if (ps.executeUpdate() == 0) { HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada")); return; }
|
||||
HttpResponses.json(ex, 200, Map.of("ok", true, "empresa_id", empresaId, "status", status));
|
||||
}
|
||||
}
|
||||
|
||||
private void registrarLembrete(HttpExchange ex, long empresaId) throws Exception {
|
||||
Map<String,String> b=body(ex); String canal=required(b,"canal");
|
||||
try(Connection conn=dataSourceProvider.getMasterConnection()){
|
||||
long periodoId=periodoAtual(conn,empresaId);
|
||||
// TODO Prompt 08: enviar lembrete real por WhatsApp/e-mail; nesta fase apenas registra a intenção.
|
||||
try(PreparedStatement ps=conn.prepareStatement("INSERT INTO lembretes_cobranca (empresa_id, periodo_uso_id, canal, enviado_por) VALUES (?,?,?,NULL) RETURNING id")){ps.setLong(1,empresaId);ps.setLong(2,periodoId);ps.setString(3,canal);try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"registrado",true));}}
|
||||
}
|
||||
}
|
||||
|
||||
private void garantirPeriodosAtuais(Connection conn)throws SQLException{try(PreparedStatement ps=conn.prepareStatement("SELECT e.id FROM empresas e JOIN assinaturas a ON a.empresa_id=e.id AND a.status='ativa' WHERE e.status<>'excluida' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next())periodoAtual(conn,rs.getLong(1));}}}
|
||||
|
||||
private long periodoAtual(Connection conn,long empresaId)throws SQLException{int dia=diaVencimento(conn,empresaId);LocalDate venc=vencimentoAtual(dia);LocalDate inicio=venc.plusDays(1);LocalDate fim=venc.plusMonths(1);LocalDate mes=venc.withDayOfMonth(1);try(PreparedStatement ps=conn.prepareStatement("INSERT INTO periodos_uso (empresa_id, mes_referencia, data_inicio, data_fim, data_vencimento, status) VALUES (?,?,?,?,?,'pendente') ON CONFLICT (empresa_id, mes_referencia) DO UPDATE SET data_inicio=EXCLUDED.data_inicio,data_fim=EXCLUDED.data_fim,data_vencimento=EXCLUDED.data_vencimento RETURNING id")){ps.setLong(1,empresaId);ps.setDate(2,java.sql.Date.valueOf(mes));ps.setDate(3,java.sql.Date.valueOf(inicio));ps.setDate(4,java.sql.Date.valueOf(fim));ps.setDate(5,java.sql.Date.valueOf(venc));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
|
||||
private LocalDate vencimentoAtual(int dia){LocalDate hoje=LocalDate.now();LocalDate base=hoje.withDayOfMonth(Math.min(dia,hoje.lengthOfMonth()));if(hoje.isBefore(base)){LocalDate anterior=hoje.minusMonths(1);base=anterior.withDayOfMonth(Math.min(dia,anterior.lengthOfMonth()));}return base;}
|
||||
private int diaVencimento(Connection conn,long empresaId)throws SQLException{Map<String,Object>a=one(conn,"SELECT dia_vencimento FROM assinaturas WHERE empresa_id=? AND status='ativa' ORDER BY id DESC LIMIT 1",List.of(empresaId));if(a==null||a.get("dia_vencimento")==null)return 7;int dia=((Number)a.get("dia_vencimento")).intValue();return Math.max(1,Math.min(28,dia));}
|
||||
|
||||
private long inserirPlano(Connection c, Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO planos (nome,valor_mensal_centavos,descricao,ativo) VALUES (?,?,?,true) RETURNING id")){ps.setString(1,required(b,"nome"));ps.setLong(2,Long.parseLong(required(b,"valor_mensal_centavos")));ps.setString(3,b.get("descricao"));try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
|
||||
private long atualizarPlanoRow(Connection c,long id,Map<String,String>b)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE planos SET nome=COALESCE(?,nome), valor_mensal_centavos=COALESCE(?,valor_mensal_centavos), descricao=COALESCE(?,descricao) WHERE id=?")){ps.setString(1,b.get("nome")); if(b.get("valor_mensal_centavos")==null)ps.setNull(2,Types.BIGINT);else ps.setLong(2,Long.parseLong(b.get("valor_mensal_centavos"))); ps.setString(3,b.get("descricao")); ps.setLong(4,id); ps.executeUpdate(); return id;}}
|
||||
private void sincronizarPlanoFuncionalidades(Connection c,long planoId,List<String> chaves)throws SQLException{try(PreparedStatement del=c.prepareStatement("DELETE FROM plano_funcionalidades WHERE plano_id=?")){del.setLong(1,planoId);del.executeUpdate();}try(PreparedStatement ins=c.prepareStatement("INSERT INTO plano_funcionalidades (plano_id,funcionalidade_id) SELECT ?, id FROM funcionalidades WHERE chave=?")){for(String chave:chaves){ins.setLong(1,planoId);ins.setString(2,chave);ins.addBatch();}ins.executeBatch();}}
|
||||
private void copiarFuncionalidadesPlano(Connection c,long empresaId,long planoId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO empresa_funcionalidades (empresa_id,funcionalidade_id,ativa) SELECT ?, funcionalidade_id, true FROM plano_funcionalidades WHERE plano_id=? ON CONFLICT (empresa_id,funcionalidade_id) DO UPDATE SET ativa=true")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.executeUpdate();}}
|
||||
private void salvarAssinatura(Connection c,long empresaId,long planoId)throws SQLException{Map<String,Object> p=one(c,"SELECT valor_mensal_centavos FROM planos WHERE id=?",List.of(planoId));if(p==null)throw new IllegalArgumentException("Plano nao encontrado");try(PreparedStatement ps=c.prepareStatement("UPDATE assinaturas SET status='inativa' WHERE empresa_id=? AND status='ativa'")){ps.setLong(1,empresaId);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO assinaturas (empresa_id,plano_id,valor_mensal_centavos,dia_vencimento,status) VALUES (?,?,?,?, 'ativa')")){ps.setLong(1,empresaId);ps.setLong(2,planoId);ps.setLong(3,((Number)p.get("valor_mensal_centavos")).longValue());ps.setInt(4,7);ps.executeUpdate();}}
|
||||
|
||||
private Map<String,Object> empresaMap(Empresa e){Map<String,Object> m=new LinkedHashMap<>();m.put("id",e.id());m.put("nome",e.nome());m.put("cnpj",e.cnpj());m.put("slug",e.slug());m.put("database_name",e.databaseName());m.put("status",e.status());m.put("criado_em",e.criadoEm());return m;}
|
||||
private Map<String,Object> funcionarioMap(Funcionario f){Map<String,Object> m=new LinkedHashMap<>();m.put("id",f.id());m.put("empresa_id",f.empresaId());m.put("nome",f.nome());m.put("email",f.email());m.put("telefone",f.telefone());m.put("cargo",f.cargo()==null?"":f.cargo());m.put("status",f.status());return m;}
|
||||
private Map<String,Object> empresaResumoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("cnpj",rs.getString("cnpj"));m.put("slug",rs.getString("slug"));m.put("database_name",rs.getString("database_name"));m.put("status",rs.getString("status"));m.put("plano_id",rs.getObject("plano_id"));m.put("plano",rs.getString("plano_nome"));m.put("plano_valor_centavos",rs.getObject("plano_valor_centavos"));m.put("dia_vencimento",rs.getObject("dia_vencimento"));m.put("periodo_status",rs.getString("periodo_status"));m.put("mes_referencia",rs.getObject("mes_referencia"));m.put("data_inicio",rs.getObject("data_inicio"));m.put("data_fim",rs.getObject("data_fim"));m.put("data_vencimento",rs.getObject("data_vencimento"));return m;}
|
||||
private Map<String,Object> planoMap(Connection c,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement("SELECT * FROM planos WHERE id=?")){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return planoMap(rs);}}}
|
||||
private Map<String,Object> planoMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();m.put("id",rs.getLong("id"));m.put("nome",rs.getString("nome"));m.put("valor_mensal_centavos",rs.getLong("valor_mensal_centavos"));m.put("descricao",rs.getString("descricao"));m.put("ativo",rs.getBoolean("ativo"));return m;}
|
||||
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++){m.put(md.getColumnLabel(i),rs.getObject(i));}return m;}
|
||||
|
||||
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
|
||||
private String emptyToNull(String v){return v==null||v.isBlank()?null:v;}
|
||||
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));}
|
||||
private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);}
|
||||
private Map<String,String> query(HttpExchange ex){Map<String,String> q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String p:raw.split("&")){String[]kv=p.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
|
||||
private String required(Map<String,String> m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;}
|
||||
private Long optionalLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);}
|
||||
private long parseId(String s){return Long.parseLong(s);}
|
||||
private boolean exists(Connection c,String sql,String value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
|
||||
private boolean exists(Connection c,String sql,long value)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,value);try(ResultSet rs=ps.executeQuery()){return rs.next();}}}
|
||||
private void bind(PreparedStatement ps,List<Object> params)throws SQLException{for(int i=0;i<params.size();i++)ps.setObject(i+1,params.get(i));}
|
||||
private List<String> stringArray(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return List.of();int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return List.of();String body=raw.substring(start+1,end).trim();if(body.isBlank())return List.of();List<String> out=new ArrayList<>();for(String part:body.split(",")){String v=part.trim();if(v.startsWith("\"")&&v.endsWith("\""))v=v.substring(1,v.length()-1);if(!v.isBlank())out.add(v);}return out;}
|
||||
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int start=raw.indexOf('[',p);int end=raw.indexOf(']',start);if(start<0||end<0)return raw;return raw.substring(0,start)+"\"\""+raw.substring(end+1);}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.seconectai.condocontab.master.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record Empresa(
|
||||
Long id,
|
||||
String nome,
|
||||
String cnpj,
|
||||
String slug,
|
||||
String databaseName,
|
||||
String status,
|
||||
LocalDateTime criadoEm
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.seconectai.condocontab.master.model;
|
||||
|
||||
public record Funcionalidade(
|
||||
Long id,
|
||||
String chave,
|
||||
String nome,
|
||||
String descricao
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.seconectai.condocontab.master.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record Funcionario(
|
||||
Long id,
|
||||
Long empresaId,
|
||||
String nome,
|
||||
String email,
|
||||
String telefone,
|
||||
String senhaHash,
|
||||
String cargo,
|
||||
String status,
|
||||
LocalDateTime criadoEm
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.seconectai.condocontab.tenant;
|
||||
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
import com.seconectai.condocontab.db.MigrationRunner;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class TenantDataSourceProvider {
|
||||
private final AppConfig config;
|
||||
private final DataSourceProvider masterDataSource;
|
||||
private final Map<Long, Connection> conexoesPorEmpresa = new ConcurrentHashMap<>();
|
||||
|
||||
public TenantDataSourceProvider(AppConfig config, DataSourceProvider masterDataSource) {
|
||||
this.config = config;
|
||||
this.masterDataSource = masterDataSource;
|
||||
}
|
||||
|
||||
public Connection getConnection(long empresaId) throws SQLException {
|
||||
Connection existente = conexoesPorEmpresa.get(empresaId);
|
||||
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
|
||||
return existente;
|
||||
}
|
||||
|
||||
synchronized (conexoesPorEmpresa) {
|
||||
existente = conexoesPorEmpresa.get(empresaId);
|
||||
if (existente != null && !existente.isClosed() && existente.isValid(2)) {
|
||||
return existente;
|
||||
}
|
||||
String databaseName = buscarDatabaseName(empresaId);
|
||||
Connection nova = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword());
|
||||
executarMigrationsTenant(nova, databaseName);
|
||||
return nova;
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidar(long empresaId) {
|
||||
Connection conn = conexoesPorEmpresa.remove(empresaId);
|
||||
if (conn != null) {
|
||||
try {
|
||||
conn.close();
|
||||
} catch (SQLException e) {
|
||||
System.err.println("Falha ao fechar conexao tenant da empresa " + empresaId + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executarMigrationsTenant(Connection conn, String databaseName) throws SQLException {
|
||||
try {
|
||||
MigrationRunner.runMigrations(conn, "migrations/tenant");
|
||||
} catch (IOException e) {
|
||||
throw new SQLException("Falha ao carregar migrations tenant para " + databaseName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buscarDatabaseName(long empresaId) throws SQLException {
|
||||
try (Connection master = masterDataSource.getMasterConnection();
|
||||
PreparedStatement ps = master.prepareStatement("SELECT database_name FROM empresas WHERE id = ?")) {
|
||||
ps.setLong(1, empresaId);
|
||||
try (var rs = ps.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
throw new SQLException("Empresa nao encontrada no master: " + empresaId);
|
||||
}
|
||||
return rs.getString("database_name");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ponto de atencao futuro: invalidar automaticamente quando empresa for bloqueada/cancelada.
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.seconectai.condocontab.tenant.dao;
|
||||
|
||||
import com.seconectai.condocontab.tenant.model.Condominio;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class CondominioDao {
|
||||
public Condominio inserir(Connection conn, Condominio condominio) throws SQLException {
|
||||
String sql = "INSERT INTO condominios (nome, cnpj, endereco) VALUES (?, ?, ?)";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
ps.setString(1, condominio.nome());
|
||||
ps.setString(2, condominio.cnpj());
|
||||
ps.setString(3, condominio.endereco());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) {
|
||||
throw new SQLException("Falha ao obter id gerado para condominio");
|
||||
}
|
||||
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Condominio> buscarPorId(Connection conn, long id) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios WHERE id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<Condominio> listarTodos(Connection conn) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM condominios ORDER BY nome")) {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<Condominio> condominios = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
condominios.add(map(rs));
|
||||
}
|
||||
return condominios;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Condominio map(ResultSet rs) throws SQLException {
|
||||
return new Condominio(
|
||||
rs.getLong("id"),
|
||||
rs.getString("nome"),
|
||||
rs.getString("cnpj"),
|
||||
rs.getString("endereco"),
|
||||
rs.getTimestamp("criado_em").toLocalDateTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.seconectai.condocontab.tenant.dao;
|
||||
|
||||
import com.seconectai.condocontab.tenant.model.ContatoCondominio;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class ContatoCondominioDao {
|
||||
public void vincular(Connection conn, ContatoCondominio vinculo) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO contato_condominio
|
||||
(contato_id, condominio_id, tipo, data_inicio_mandato, data_fim_mandato, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (contato_id, condominio_id, tipo) DO UPDATE SET
|
||||
data_inicio_mandato = EXCLUDED.data_inicio_mandato,
|
||||
data_fim_mandato = EXCLUDED.data_fim_mandato,
|
||||
status = EXCLUDED.status
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, vinculo.contatoId());
|
||||
ps.setLong(2, vinculo.condominioId());
|
||||
ps.setString(3, vinculo.tipo());
|
||||
ps.setDate(4, vinculo.dataInicioMandato() == null ? null : Date.valueOf(vinculo.dataInicioMandato()));
|
||||
ps.setDate(5, vinculo.dataFimMandato() == null ? null : Date.valueOf(vinculo.dataFimMandato()));
|
||||
ps.setString(6, vinculo.status() == null ? "ativo" : vinculo.status());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<ContatoCondominio> listarPorCondominio(Connection conn, long condominioId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
|
||||
FROM contato_condominio cc
|
||||
JOIN contatos c ON c.id = cc.contato_id
|
||||
WHERE cc.condominio_id = ?
|
||||
ORDER BY cc.tipo, c.nome
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, condominioId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<ContatoCondominio> vinculos = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
vinculos.add(map(rs));
|
||||
}
|
||||
return vinculos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ContatoCondominio> buscarSindicoAtual(Connection conn, long condominioId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT cc.*, c.nome AS contato_nome, c.telefone AS contato_telefone, c.email AS contato_email
|
||||
FROM contato_condominio cc
|
||||
JOIN contatos c ON c.id = cc.contato_id
|
||||
WHERE cc.condominio_id = ?
|
||||
AND cc.tipo = 'sindico'
|
||||
AND cc.status = 'ativo'
|
||||
ORDER BY cc.data_inicio_mandato DESC NULLS LAST, c.nome
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setLong(1, condominioId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ContatoCondominio map(ResultSet rs) throws SQLException {
|
||||
Date inicio = rs.getDate("data_inicio_mandato");
|
||||
Date fim = rs.getDate("data_fim_mandato");
|
||||
return new ContatoCondominio(
|
||||
rs.getLong("contato_id"),
|
||||
rs.getLong("condominio_id"),
|
||||
rs.getString("tipo"),
|
||||
inicio == null ? null : inicio.toLocalDate(),
|
||||
fim == null ? null : fim.toLocalDate(),
|
||||
rs.getString("status"),
|
||||
rs.getString("contato_nome"),
|
||||
rs.getString("contato_telefone"),
|
||||
rs.getString("contato_email")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.seconectai.condocontab.tenant.dao;
|
||||
|
||||
import com.seconectai.condocontab.tenant.model.Contato;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class ContatoDao {
|
||||
public Contato inserir(Connection conn, Contato contato) throws SQLException {
|
||||
String sql = "INSERT INTO contatos (nome, telefone, email) VALUES (?, ?, ?)";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
ps.setString(1, contato.nome());
|
||||
ps.setString(2, contato.telefone());
|
||||
ps.setString(3, contato.email());
|
||||
ps.executeUpdate();
|
||||
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||
if (!keys.next()) {
|
||||
throw new SQLException("Falha ao obter id gerado para contato");
|
||||
}
|
||||
return buscarPorId(conn, keys.getLong(1)).orElseThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Contato> buscarPorId(Connection conn, long id) throws SQLException {
|
||||
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM contatos WHERE id = ?")) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Optional.of(map(rs)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Contato map(ResultSet rs) throws SQLException {
|
||||
return new Contato(
|
||||
rs.getLong("id"),
|
||||
rs.getString("nome"),
|
||||
rs.getString("telefone"),
|
||||
rs.getString("email"),
|
||||
rs.getTimestamp("criado_em").toLocalDateTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package com.seconectai.condocontab.tenant.http;
|
||||
|
||||
import com.seconectai.condocontab.auth.http.AuthMiddleware;
|
||||
import com.seconectai.condocontab.config.DataSourceProvider;
|
||||
import com.seconectai.condocontab.http.HttpResponses;
|
||||
import com.seconectai.condocontab.integracao.EmailClient;
|
||||
import com.seconectai.condocontab.integracao.EvolutionApiClient;
|
||||
import com.seconectai.condocontab.tenant.TenantDataSourceProvider;
|
||||
import com.seconectai.condocontab.util.Json;
|
||||
import com.seconectai.condocontab.util.SimpleJson;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.*;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
public final class ClienteHandlers implements HttpHandler {
|
||||
private static final long MAX_UPLOAD = 10L * 1024L * 1024L;
|
||||
private static final Set<String> EXT_OK = Set.of("pdf", "jpg", "jpeg", "png", "docx");
|
||||
private final TenantDataSourceProvider tenantDs;
|
||||
private final DataSourceProvider masterDs;
|
||||
private final Path uploadRoot;
|
||||
private final EvolutionApiClient evolutionApiClient;
|
||||
private final EmailClient emailClient;
|
||||
|
||||
public ClienteHandlers(TenantDataSourceProvider tenantDs, DataSourceProvider masterDs, Path uploadRoot, EvolutionApiClient evolutionApiClient, EmailClient emailClient) {
|
||||
this.tenantDs = tenantDs;
|
||||
this.masterDs = masterDs;
|
||||
this.uploadRoot = uploadRoot;
|
||||
this.evolutionApiClient = evolutionApiClient;
|
||||
this.emailClient = emailClient;
|
||||
}
|
||||
|
||||
@Override public void handle(HttpExchange ex) throws IOException {
|
||||
try { route(ex); }
|
||||
catch (IllegalArgumentException e) { HttpResponses.json(ex,400,Map.of("erro","requisicao_invalida","mensagem",e.getMessage())); }
|
||||
catch (SQLException e) { HttpResponses.json(ex,500,Map.of("erro","erro_banco","mensagem",e.getMessage())); }
|
||||
catch (Throwable e) { e.printStackTrace(System.err); HttpResponses.json(ex,500,Map.of("erro","erro_interno","mensagem",String.valueOf(e.getMessage()))); }
|
||||
}
|
||||
|
||||
private void route(HttpExchange ex) throws Exception {
|
||||
String method=ex.getRequestMethod(); String path=ex.getRequestURI().getPath();
|
||||
String[] p=Arrays.stream(path.substring("/api".length()).split("/")).filter(s->!s.isBlank()).map(s->URLDecoder.decode(s,StandardCharsets.UTF_8)).toArray(String[]::new);
|
||||
long empresaId=(long)ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID); long funcionarioId=(long)ex.getAttribute(AuthMiddleware.ATTR_FUNCIONARIO_ID); String cargo=String.valueOf(ex.getAttribute(AuthMiddleware.ATTR_CARGO));
|
||||
try(Connection c=tenantDs.getConnection(empresaId)){
|
||||
if(p.length==1&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} if("POST".equals(method)) criarFuncionarioEmpresa(ex,empresaId); else if("GET".equals(method)) listarFuncionariosEmpresa(ex,empresaId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==2&&"funcionarios".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar funcionarios."));return;} long func=id(p[1]); if("PUT".equals(method)) atualizarFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else if("DELETE".equals(method)) excluirFuncionarioEmpresa(ex,empresaId,func,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==1&&"lembrete-pessoas".equals(p[0])&&"GET".equals(method)){ listarPessoasLembrete(ex,empresaId); return; }
|
||||
if(p.length==1&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} if("POST".equals(method)) criarExtra(ex,c,funcionarioId); else if("GET".equals(method)) listarExtras(ex,c); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==2&&"extras".equals(p[0])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode gerenciar destinatarios extras."));return;} long extra=id(p[1]); if("PUT".equals(method)) atualizarExtra(ex,c,extra,funcionarioId); else if("DELETE".equals(method)) excluirExtra(ex,c,extra,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==1&&"lembretes".equals(p[0])){ if("POST".equals(method)) criarLembrete(ex,c,empresaId,funcionarioId); else if("GET".equals(method)) listarLembretes(ex,c,empresaId,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==3&&"lembretes".equals(p[0])&&"enviar".equals(p[2])&&"POST".equals(method)){ enviarLembreteManual(ex,c,empresaId,id(p[1]),cargo,funcionarioId); return; }
|
||||
if(p.length==2&&"lembretes".equals(p[0])){ long lembrete=id(p[1]); if("PUT".equals(method)) atualizarLembrete(ex,c,empresaId,lembrete,cargo,funcionarioId); else if("DELETE".equals(method)) excluirLembrete(ex,c,lembrete,cargo,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal da empresa pode configurar o WhatsApp."));return;} }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"conectar".equals(p[2])&&"POST".equals(method)){ conectarEvolution(ex,empresaId); return; }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"status".equals(p[2])&&"GET".equals(method)){ statusEvolution(ex,empresaId); return; }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"qrcode".equals(p[2])&&"POST".equals(method)){ qrcodeEvolution(ex,empresaId); return; }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"desconectar".equals(p[2])&&"POST".equals(method)){ desconectarEvolution(ex,empresaId); return; }
|
||||
if(p.length==3&&"config".equals(p[0])&&"evolution-instancia".equals(p[1])&&"reconectar".equals(p[2])&&"POST".equals(method)){ reconectarEvolution(ex,empresaId); return; }
|
||||
if(p.length==1&&"assembleias-calendario".equals(p[0])&&"GET".equals(method)){ listarAssembleiasCalendario(ex,c,empresaId,cargo,funcionarioId); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAviso(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarAvisos(ex,c,cond); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"avisos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheAviso(ex,c,cond,id(p[3])); else if("DELETE".equals(method)) excluirAviso(ex,c,empresaId,cond,id(p[3]),funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==5&&"condominios".equals(p[0])&&"avisos".equals(p[2])&&"reenviar".equals(p[4])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} reenviarAviso(ex,c,empresaId,cond,id(p[3])); return; }
|
||||
if(p.length==1&&"condominios".equals(p[0])){ if("POST".equals(method)) criarCondominio(ex,c,funcionarioId,cargo); else if("GET".equals(method)) listarCondominios(ex,c,funcionarioId,cargo); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==2&&"condominios".equals(p[0])){ long id=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,id)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("GET".equals(method)) detalheCondominio(ex,c,id); else if("PUT".equals(method)) atualizarCondominio(ex,c,id,funcionarioId); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==1&&"contatos".equals(p[0])&&"POST".equals(method)){ criarContato(ex,c,funcionarioId); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"GET".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarContatos(ex,c,cond); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"vincular".equals(p[3])&&"POST".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} vincularContato(ex,c,cond,funcionarioId); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"contatos".equals(p[2])&&"DELETE".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} desvincularContato(ex,c,cond,id(p[3]),funcionarioId); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])){ long cond=id(p[1]); if("POST".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode vincular funcionarios a condominios."));return;} vincularFuncionario(ex,c,empresaId,cond,funcionarioId); } else if("GET".equals(method)){ if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} listarFuncionariosVinculados(ex,c,empresaId,cond); } else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"funcionarios-vinculados".equals(p[2])&&"DELETE".equals(method)){ if(!adminEmpresa(cargo)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas a conta principal pode desvincular funcionarios de condominios."));return;} execWrite(ex,c,"DELETE FROM funcionario_condominio WHERE condominio_id=? AND funcionario_id=?",Arrays.asList(id(p[1]),id(p[3])),funcionarioId,"funcionario_condominio",id(p[3])); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarEspaco(ex,c,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM espacos_comuns WHERE condominio_id=? ORDER BY nome",cond,"espacos"); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"espacos-comuns".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarEspaco(ex,c,cond,id(p[3]),funcionarioId); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"assembleias".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) criarAssembleia(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM assembleias WHERE condominio_id=? ORDER BY data_hora DESC",cond,"assembleias"); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==4&&"condominios".equals(p[0])&&"assembleias".equals(p[2])&&"PUT".equals(method)){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} atualizarAssembleia(ex,c,cond,id(p[3]),funcionarioId); return; }
|
||||
if(p.length==3&&"condominios".equals(p[0])&&"documentos".equals(p[2])){ long cond=id(p[1]); if(!podeAcessarCondominio(c,cargo,funcionarioId,cond)){HttpResponses.json(ex,403,Map.of("erro","condominio_nao_vinculado"));return;} if("POST".equals(method)) uploadDocumento(ex,c,empresaId,cond,funcionarioId); else if("GET".equals(method)) listarParam(ex,c,"SELECT * FROM documentos WHERE condominio_id=? ORDER BY criado_em DESC",cond,"documentos"); else HttpResponses.methodNotAllowed(ex); return; }
|
||||
if(p.length==3&&"documentos".equals(p[0])&&"download".equals(p[2])&&"GET".equals(method)){ downloadDocumento(ex,c,id(p[1])); return; }
|
||||
HttpResponses.json(ex,404,Map.of("erro","endpoint_nao_encontrado"));
|
||||
}
|
||||
}
|
||||
|
||||
private void criarCondominio(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{Map<String,String>b=body(ex);String cnpj=cnpj(req(b,"cnpj"));if(one(c,"SELECT id FROM condominios WHERE regexp_replace(cnpj,'\\D','','g')=?",Arrays.asList(cnpj))!=null){HttpResponses.json(ex,400,Map.of("erro","cnpj_duplicado","mensagem","Ja existe um condominio cadastrado com este CNPJ."));return;}c.setAutoCommit(false);try{long id=insert(c,"INSERT INTO condominios (nome,cnpj,endereco) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),cnpj,nullable(b.get("endereco"))));if(!adminEmpresa(cargo)){try(PreparedStatement ps=c.prepareStatement("INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,fid);ps.setLong(2,id);ps.executeUpdate();}}audit(c,fid,"create","condominios",id,null,b);c.commit();detalheCondominio(ex,c,id);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
|
||||
private void listarCondominios(HttpExchange ex,Connection c,long fid,String cargo)throws Exception{if(adminEmpresa(cargo)){listar(ex,c,"SELECT * FROM condominios ORDER BY nome","condominios");return;}HttpResponses.json(ex,200,Map.of("condominios",rows(c,"SELECT c.* FROM condominios c JOIN funcionario_condominio fc ON fc.condominio_id=c.id WHERE fc.funcionario_id=? ORDER BY c.nome",Arrays.asList(fid))));}
|
||||
private boolean podeAcessarCondominio(Connection c,String cargo,long fid,long cond)throws SQLException{return adminEmpresa(cargo)||count(c,"SELECT count(*) FROM funcionario_condominio WHERE funcionario_id=? AND condominio_id=?",fid,cond)>0;}
|
||||
private void detalheCondominio(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object> cond=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(cond==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} Map<String,Object> sind=one(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.tipo='sindico' AND cc.status='ativo' ORDER BY cc.data_inicio_mandato DESC NULLS LAST LIMIT 1",Arrays.asList(id)); long ass=count(c,"SELECT count(*) FROM assembleias WHERE condominio_id=? AND data_hora>now() AND status='agendada'",id); long av=count(c,"SELECT count(*) FROM avisos WHERE condominio_id=? AND (data_expiracao IS NULL OR data_expiracao>now())",id); Map<String,Object> resp=new LinkedHashMap<>();resp.put("condominio",cond);resp.put("sindico_atual",sind);resp.put("assembleias_futuras",ass);resp.put("avisos_ativos",av);HttpResponses.json(ex,200,resp);}
|
||||
private void atualizarCondominio(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM condominios WHERE id=?",Arrays.asList(id)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","condominio_nao_encontrado"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE condominios SET nome=COALESCE(?,nome), cnpj=COALESCE(?,cnpj), endereco=COALESCE(?,endereco) WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("cnpj"));ps.setString(3,b.get("endereco"));ps.setLong(4,id);ps.executeUpdate();} audit(c,fid,"update","condominios",id,old,b); detalheCondominio(ex,c,id);}
|
||||
private void criarContato(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email")))); audit(c,fid,"create","contatos",id,null,b); HttpResponses.json(ex,201,Map.of("contato",one(c,"SELECT * FROM contatos WHERE id=?",Arrays.asList(id))));}
|
||||
private void vincularContato(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); Long contato=optLong(b.get("contato_id")); if(contato==null){contato=insert(c,"INSERT INTO contatos (nome,telefone,email) VALUES (?,?,?)",Arrays.asList(req(b,"nome"),nullable(b.get("telefone")),nullable(b.get("email"))));} String tipo=req(b,"tipo"); if("sindico".equals(tipo)){try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND tipo='sindico' AND status='ativo' AND contato_id<>?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.executeUpdate();}} try(PreparedStatement ps=c.prepareStatement("INSERT INTO contato_condominio (contato_id,condominio_id,tipo,data_inicio_mandato,data_fim_mandato,status) VALUES (?,?,?,?,?,'ativo') ON CONFLICT (contato_id,condominio_id,tipo) DO UPDATE SET data_inicio_mandato=EXCLUDED.data_inicio_mandato,data_fim_mandato=EXCLUDED.data_fim_mandato,status='ativo'")){ps.setLong(1,contato);ps.setLong(2,cond);ps.setString(3,tipo);setDate(ps,4,b.get("data_inicio_mandato"));setDate(ps,5,b.get("data_fim_mandato"));ps.executeUpdate();} if(Set.of("sindico","subsindico","conselheiro").contains(tipo))vincularContatoAosAvisosRecorrentes(c,cond,contato); audit(c,fid,"create","contato_condominio",contato,null,b); listarContatos(ex,c,cond);}
|
||||
private void vincularContatoAosAvisosRecorrentes(Connection c,long cond,long contato)throws SQLException{
|
||||
List<Map<String,Object>> avisos=rows(c,"SELECT id FROM avisos WHERE condominio_id=? AND recorrencia_ativa=true AND recorrencia_meses>0 AND (enviar_whatsapp=true OR enviar_email=true)",Arrays.asList(cond));
|
||||
for(Map<String,Object>a:avisos){
|
||||
long aviso=((Number)a.get("id")).longValue();
|
||||
try(PreparedStatement ps=c.prepareStatement("INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING")){ps.setLong(1,aviso);ps.setString(2,"contato");ps.setLong(3,contato);ps.executeUpdate();}
|
||||
enfileirarAviso(c,aviso,Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
|
||||
}
|
||||
}
|
||||
private void listarContatos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("contatos",rows(c,"SELECT cc.*, c.nome contato_nome,c.telefone contato_telefone,c.email contato_email FROM contato_condominio cc JOIN contatos c ON c.id=cc.contato_id WHERE cc.condominio_id=? AND cc.status='ativo' ORDER BY cc.tipo,c.nome",Arrays.asList(cond))));}
|
||||
private void desvincularContato(HttpExchange ex,Connection c,long cond,long contato,long fid)throws Exception{String tipo=query(ex).get("tipo"); try(PreparedStatement ps=c.prepareStatement("UPDATE contato_condominio SET status='inativo', data_fim_mandato=current_date WHERE condominio_id=? AND contato_id=? AND tipo=?")){ps.setLong(1,cond);ps.setLong(2,contato);ps.setString(3,tipo);ps.executeUpdate();} audit(c,fid,"delete","contato_condominio",contato,null,Map.of("tipo",tipo)); HttpResponses.json(ex,200,Map.of("ok",true));}
|
||||
private void vincularFuncionario(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long func=Long.parseLong(req(b,"funcionario_id")); try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT 1 FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(!rs.next()){HttpResponses.json(ex,400,Map.of("erro","funcionario_invalido"));return;}}} execWrite(ex,c,"INSERT INTO funcionario_condominio (funcionario_id,condominio_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(func,cond),fid,"funcionario_condominio",func);}
|
||||
private void listarFuncionariosVinculados(HttpExchange ex,Connection c,long emp,long cond)throws Exception{List<Map<String,Object>> links=rows(c,"SELECT * FROM funcionario_condominio WHERE condominio_id=? ORDER BY funcionario_id",Arrays.asList(cond));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> link:links){long func=((Number)link.get("funcionario_id")).longValue();ps.setLong(1,func);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next()){Map<String,Object> item=resultMap(rs);item.put("funcionario_id",func);item.put("condominio_id",cond);out.add(item);}}}}HttpResponses.json(ex,200,Map.of("funcionarios",out));}
|
||||
private void criarEspaco(HttpExchange ex,Connection c,long cond,long fid)throws Exception{Map<String,String>b=body(ex); long id=insert(c,"INSERT INTO espacos_comuns (condominio_id,nome,descricao,valor_utilizacao_centavos,indice_correcao) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"nome"),nullable(b.get("descricao")),optLong(b.get("valor_utilizacao_centavos")),nullable(b.get("indice_correcao")))); audit(c,fid,"create","espacos_comuns",id,null,b); HttpResponses.json(ex,201,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(id))));}
|
||||
private void atualizarEspaco(HttpExchange ex,Connection c,long cond,long eid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM espacos_comuns WHERE id=? AND condominio_id=?",Arrays.asList(eid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","espaco_nao_encontrado"));return;} Long novo=optLong(b.get("valor_utilizacao_centavos")); try(PreparedStatement ps=c.prepareStatement("UPDATE espacos_comuns SET nome=COALESCE(?,nome), descricao=COALESCE(?,descricao), valor_utilizacao_centavos=COALESCE(?,valor_utilizacao_centavos), indice_correcao=COALESCE(?,indice_correcao), data_ultimo_reajuste=CASE WHEN ? IS NOT NULL AND ?<>valor_utilizacao_centavos THEN current_date ELSE data_ultimo_reajuste END WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("descricao")); if(novo==null)ps.setNull(3,Types.BIGINT);else ps.setLong(3,novo); ps.setString(4,b.get("indice_correcao")); if(novo==null){ps.setNull(5,Types.BIGINT);ps.setNull(6,Types.BIGINT);}else{ps.setLong(5,novo);ps.setLong(6,novo);} ps.setLong(7,eid);ps.setLong(8,cond);ps.executeUpdate();} audit(c,fid,"update","espacos_comuns",eid,old,b); HttpResponses.json(ex,200,Map.of("espaco",one(c,"SELECT * FROM espacos_comuns WHERE id=?",Arrays.asList(eid))));}
|
||||
private void criarAssembleia(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{
|
||||
Map<String,String>b=body(ex);
|
||||
long id=insert(c,"INSERT INTO assembleias (condominio_id,tipo,data_hora,local,pauta) VALUES (?,?,?,?,?)",Arrays.asList(cond,req(b,"tipo"),ts(req(b,"data_hora")),nullable(b.get("local")),nullable(b.get("pauta"))));
|
||||
audit(c,fid,"create","assembleias",id,null,b);
|
||||
Map<String,Object> envio=criarAvisoAssembleia(c,emp,cond,id,fid,b);
|
||||
HttpResponses.json(ex,201,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(id)),"envio",envio));
|
||||
}
|
||||
private Map<String,Object> criarAvisoAssembleia(Connection c,long emp,long cond,long assembleia,long fid,Map<String,String>b)throws Exception{
|
||||
List<Map<String,Object>> contatosAviso=rows(c,"SELECT contato_id FROM contato_condominio WHERE condominio_id=? AND status=?",Arrays.asList(cond,"ativo"));
|
||||
List<Map<String,Object>> funcionariosAviso=rows(c,"SELECT funcionario_id FROM funcionario_condominio WHERE condominio_id=?",Arrays.asList(cond));
|
||||
int destinatarios=contatosAviso.size()+funcionariosAviso.size();
|
||||
if(destinatarios==0)return Map.of("enviados",0,"falhas",0,"mensagem","Nenhum destinatario vinculado ao condominio.");
|
||||
if(!funcionalidadeAtiva(emp,"whatsapp_envio"))return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao liberado para esta empresa.");
|
||||
if(evolutionAprovada(emp)==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
|
||||
String titulo="Assembleia " + req(b,"tipo");
|
||||
StringBuilder mensagem=new StringBuilder();
|
||||
mensagem.append("Nova assembleia agendada.");
|
||||
mensagem.append("\nData/hora: ").append(req(b,"data_hora").replace('T',' '));
|
||||
if(b.get("local")!=null&&!b.get("local").isBlank())mensagem.append("\nLocal: ").append(b.get("local"));
|
||||
if(b.get("pauta")!=null&&!b.get("pauta").isBlank())mensagem.append("\nPauta: ").append(b.get("pauta"));
|
||||
long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario) VALUES (?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,assembleia,"assembleia",titulo,mensagem.toString(),ts(b.get("data_hora")),"normal",true,false,"nao_enviado",fid));
|
||||
for(Map<String,Object> row:contatosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"contato",((Number)row.get("contato_id")).longValue()));
|
||||
for(Map<String,Object> row:funcionariosAviso)insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,"funcionario",((Number)row.get("funcionario_id")).longValue()));
|
||||
enfileirarAviso(c,aviso,ts(b.get("data_hora")));
|
||||
return Map.of("aviso_id",aviso,"destinatarios",destinatarios,"status","enfileirado");
|
||||
}
|
||||
private void atualizarAssembleia(HttpExchange ex,Connection c,long cond,long aid,long fid)throws Exception{Map<String,String>b=body(ex); Map<String,Object> old=one(c,"SELECT * FROM assembleias WHERE id=? AND condominio_id=?",Arrays.asList(aid,cond)); if(old==null){HttpResponses.json(ex,404,Map.of("erro","assembleia_nao_encontrada"));return;} try(PreparedStatement ps=c.prepareStatement("UPDATE assembleias SET tipo=COALESCE(?,tipo), data_hora=COALESCE(?,data_hora), local=COALESCE(?,local), pauta=COALESCE(?,pauta), status=COALESCE(?,status) WHERE id=? AND condominio_id=?")){ps.setString(1,b.get("tipo")); if(b.get("data_hora")==null)ps.setNull(2,Types.TIMESTAMP);else ps.setTimestamp(2,ts(b.get("data_hora"))); ps.setString(3,b.get("local"));ps.setString(4,b.get("pauta"));ps.setString(5,b.get("status"));ps.setLong(6,aid);ps.setLong(7,cond);ps.executeUpdate();} audit(c,fid,"update","assembleias",aid,old,b); HttpResponses.json(ex,200,Map.of("assembleia",one(c,"SELECT * FROM assembleias WHERE id=?",Arrays.asList(aid))));}
|
||||
private void uploadDocumento(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{Multipart mp=parseMultipart(ex); if(mp.bytes.length>MAX_UPLOAD){HttpResponses.json(ex,400,Map.of("erro","arquivo_muito_grande"));return;} String ext=ext(mp.filename); if(!EXT_OK.contains(ext)){HttpResponses.json(ex,400,Map.of("erro","extensao_nao_permitida"));return;} String db=databaseName(emp); Path dir=uploadRoot.resolve(db).resolve(String.valueOf(cond)); Files.createDirectories(dir); String safe=UUID.randomUUID()+"-"+mp.filename.replaceAll("[^a-zA-Z0-9._-]","_"); Path file=dir.resolve(safe); Files.write(file,mp.bytes); String rel=db+"/"+cond+"/"+safe; long id=insert(c,"INSERT INTO documentos (condominio_id,tipo,nome_arquivo,caminho_armazenamento,enviado_por) VALUES (?,?,?,?,?)",Arrays.asList(cond,nullable(mp.tipo),mp.filename,rel,fid)); audit(c,fid,"create","documentos",id,null,Map.of("nome_arquivo",mp.filename)); HttpResponses.json(ex,201,Map.of("documento",one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id))));}
|
||||
private void downloadDocumento(HttpExchange ex,Connection c,long id)throws Exception{Map<String,Object>d=one(c,"SELECT * FROM documentos WHERE id=?",Arrays.asList(id)); if(d==null){HttpResponses.json(ex,404,Map.of("erro","documento_nao_encontrado"));return;} Path f=uploadRoot.resolve(String.valueOf(d.get("caminho_armazenamento"))); if(!Files.exists(f)){HttpResponses.json(ex,404,Map.of("erro","arquivo_nao_encontrado"));return;} ex.getResponseHeaders().set("Content-Disposition","attachment; filename=\""+d.get("nome_arquivo")+"\""); ex.sendResponseHeaders(200,Files.size(f)); Files.copy(f,ex.getResponseBody()); ex.close();}
|
||||
|
||||
|
||||
private void listarExtras(HttpExchange ex,Connection c)throws Exception{HttpResponses.json(ex,200,Map.of("extras",rows(c,"SELECT * FROM destinatarios_extra WHERE status<>'excluido' ORDER BY nome",Arrays.asList())));}
|
||||
private void criarExtra(HttpExchange ex,Connection c,long fid)throws Exception{Map<String,String>b=body(ex);long id=insert(c,"INSERT INTO destinatarios_extra (nome,email,telefone,status) VALUES (?,?,?,'ativo')",Arrays.asList(req(b,"nome"),nullable(b.get("email")),nullable(b.get("telefone"))));audit(c,fid,"create","destinatarios_extra",id,null,b);HttpResponses.json(ex,201,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
|
||||
private void atualizarExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}Map<String,String>b=body(ex);try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("nome"));ps.setString(2,b.get("email"));ps.setString(3,b.get("telefone"));ps.setString(4,b.get("status"));ps.setLong(5,id);ps.executeUpdate();}audit(c,fid,"update","destinatarios_extra",id,old,b);HttpResponses.json(ex,200,Map.of("extra",one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id))));}
|
||||
private void excluirExtra(HttpExchange ex,Connection c,long id,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM destinatarios_extra WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","extra_nao_encontrado"));return;}try(PreparedStatement ps=c.prepareStatement("UPDATE destinatarios_extra SET status='excluido', atualizado_em=now() WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","destinatarios_extra",id,old,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
|
||||
private void listarPessoasLembrete(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE empresa_id=? AND status='ativo' ORDER BY nome")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("pessoas",out));}}
|
||||
private void criarLembrete(HttpExchange ex,Connection c,long emp,long fid)throws Exception{
|
||||
String raw=rawBody(ex);
|
||||
Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));
|
||||
List<Long>dests=longArray(raw,"destinatarios");
|
||||
Timestamp dataHora=ts(b.get("data_hora"));
|
||||
boolean enviarAgora=dataHora==null||!dataHora.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));
|
||||
if(enviarAgora&&!dests.isEmpty()&&evolutionAprovada(emp)==null){
|
||||
HttpResponses.json(ex,400,Map.of("erro","instancia_whatsapp_indisponivel","mensagem","Configure, conecte e aprove o WhatsApp antes de enviar lembretes."));
|
||||
return;
|
||||
}
|
||||
long id;
|
||||
c.setAutoCommit(false);
|
||||
try{
|
||||
id=insert(c,"INSERT INTO lembretes (titulo,mensagem,data_hora,status,criado_por_funcionario) VALUES (?,?,?,?,?)",Arrays.asList(req(b,"titulo"),req(b,"mensagem"),dataHora,b.getOrDefault("status","ativo"),fid));
|
||||
salvarDestinatariosLembrete(c,id,dests);
|
||||
if(!dests.isEmpty()&&"ativo".equalsIgnoreCase(b.getOrDefault("status","ativo")))enfileirarLembrete(c,id,dataHora);
|
||||
audit(c,fid,"create","lembretes",id,null,b);
|
||||
c.commit();
|
||||
}catch(Exception err){
|
||||
c.rollback();
|
||||
throw err;
|
||||
}finally{
|
||||
c.setAutoCommit(true);
|
||||
}
|
||||
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
|
||||
HttpResponses.json(ex,201,Map.of("lembrete",lembrete,"envio",Map.of("enfileirado",!dests.isEmpty(),"agendado",!enviarAgora)));
|
||||
}
|
||||
private void listarLembretes(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{List<Map<String,Object>> base=rows(c,"SELECT l.*, CASE WHEN l.criado_por_funcionario=? THEN true ELSE false END AS criado_por_mim FROM lembretes l ORDER BY COALESCE(l.data_hora,l.criado_em) DESC,l.id DESC",Arrays.asList(fid));for(Map<String,Object> l:base){long id=((Number)l.get("id")).longValue();boolean podeAlterar=adminEmpresa(cargo)||Boolean.TRUE.equals(l.get("criado_por_mim"));String status=str(l.get("status"));l.put("pode_alterar",podeAlterar);l.put("pode_editar",podeAlterar&&!"concluido".equalsIgnoreCase(status)&&!"enviado".equalsIgnoreCase(status));l.put("destinatarios",destinatariosLembrete(c,emp,id));}HttpResponses.json(ex,200,Map.of("lembretes",base));}
|
||||
private void enviarLembreteManual(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{
|
||||
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,id);
|
||||
if(lembrete==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}
|
||||
if(!podeAlterarLembrete(lembrete,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode enviar."));return;}
|
||||
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
|
||||
if(dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o lembrete."));return;}
|
||||
Map<String,Object> envio=enviarLembreteWhatsApp(emp,id,lembrete);
|
||||
if(Number.class.isInstance(envio.get("falhas"))&&((Number)envio.get("falhas")).intValue()==0&&Number.class.isInstance(envio.get("enviados"))&&((Number)envio.get("enviados")).intValue()>0){finalizarOuReagendarLembrete(c,id);}
|
||||
HttpResponses.json(ex,200,Map.of("envio",envio));
|
||||
}
|
||||
private void atualizarLembrete(HttpExchange ex,Connection c,long emp,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode alterar."));return;}String statusAtual=str(old.get("status"));if("concluido".equalsIgnoreCase(statusAtual)||"enviado".equalsIgnoreCase(statusAtual)){HttpResponses.json(ex,409,Map.of("erro","lembrete_ja_enviado","mensagem","Este lembrete ja foi enviado e nao pode mais ser editado."));return;}String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(raw,"destinatarios"));List<Long>dests=longArray(raw,"destinatarios");c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET titulo=COALESCE(?,titulo), mensagem=COALESCE(?,mensagem), data_hora=COALESCE(?,data_hora), status=COALESCE(?,status), recorrencia_meses=?, recorrencia_ativa=?, atualizado_em=now() WHERE id=?")){ps.setString(1,b.get("titulo"));ps.setString(2,b.get("mensagem"));if(b.containsKey("data_hora")){Timestamp t=ts(b.get("data_hora"));if(t==null)ps.setNull(3,Types.TIMESTAMP);else ps.setTimestamp(3,t);}else ps.setNull(3,Types.TIMESTAMP);ps.setString(4,b.get("status"));int rec=recorrenciaMeses(b.get("recorrencia_meses"));ps.setInt(5,rec);ps.setBoolean(6,rec>0);ps.setLong(7,id);ps.executeUpdate();}if(raw.contains("\"destinatarios\""))salvarDestinatariosLembrete(c,id,dests);Map<String,Object> atual=one(c,"SELECT status,data_hora FROM lembretes WHERE id=?",Arrays.asList(id));if(atual!=null&&"ativo".equalsIgnoreCase(str(atual.get("status"))))enfileirarLembrete(c,id,(Timestamp)atual.get("data_hora"));audit(c,fid,"update","lembretes",id,old,b);c.commit();HttpResponses.json(ex,200,Map.of("lembrete",lembreteComDetalhes(c,emp,id)));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
|
||||
private void excluirLembrete(HttpExchange ex,Connection c,long id,String cargo,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(old==null){HttpResponses.json(ex,404,Map.of("erro","lembrete_nao_encontrado"));return;}if(!podeAlterarLembrete(old,cargo,fid)){HttpResponses.json(ex,403,Map.of("erro","acesso_restrito","mensagem","Apenas o criador do lembrete ou a conta principal pode excluir."));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembretes WHERE id=?")){ps.setLong(1,id);ps.executeUpdate();}audit(c,fid,"delete","lembretes",id,old,Map.of("ok",true));c.commit();HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
|
||||
private boolean podeAlterarLembrete(Map<String,Object> lembrete,String cargo,long fid){return adminEmpresa(cargo)||((Number)lembrete.get("criado_por_funcionario")).longValue()==fid;}
|
||||
private Map<String,Object> enviarLembreteWhatsApp(long emp,long lembreteId,Map<String,Object> lembrete)throws Exception{
|
||||
Map<String,Object> inst=evolutionAprovada(emp);
|
||||
if(inst==null)return Map.of("enviados",0,"falhas",1,"mensagem","WhatsApp nao aprovado para esta empresa.");
|
||||
String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);
|
||||
String texto="Lembrete: "+str(lembrete.get("titulo"))+"\n\n"+str(lembrete.get("mensagem"));
|
||||
List<Map<String,Object>> dests=(List<Map<String,Object>>)lembrete.getOrDefault("destinatarios",List.of());
|
||||
int enviados=0, falhas=0;
|
||||
List<String> detalhes=new ArrayList<>();
|
||||
for(Map<String,Object> dest:dests){
|
||||
long did=((Number)dest.get("id")).longValue();
|
||||
String nome=str(dest.get("nome"));
|
||||
String tel=telefoneWhatsapp(dest.get("telefone"));
|
||||
try{
|
||||
if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");
|
||||
String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,texto);
|
||||
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","enviado",limite(resposta));
|
||||
enviados++;
|
||||
}catch(Exception err){
|
||||
falhas++;
|
||||
detalhes.add(nome+": "+err.getMessage());
|
||||
logEnvio(emp,0,lembreteId,did,nome,"whatsapp","falhou",limite(err.getMessage()));
|
||||
}
|
||||
}
|
||||
Map<String,Object> out=new LinkedHashMap<>();
|
||||
out.put("enviados",enviados);
|
||||
out.put("falhas",falhas);
|
||||
if(!detalhes.isEmpty())out.put("detalhes",detalhes);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void salvarDestinatariosLembrete(Connection c,long lembrete,List<Long>dests)throws SQLException{try(PreparedStatement ps=c.prepareStatement("DELETE FROM lembrete_destinatarios WHERE lembrete_id=?")){ps.setLong(1,lembrete);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("INSERT INTO lembrete_destinatarios (lembrete_id,funcionario_id) VALUES (?,?) ON CONFLICT DO NOTHING")){for(Long d:dests){ps.setLong(1,lembrete);ps.setLong(2,d);ps.addBatch();}ps.executeBatch();}}
|
||||
private Map<String,Object> lembreteComDetalhes(Connection c,long emp,long id)throws Exception{Map<String,Object> l=one(c,"SELECT * FROM lembretes WHERE id=?",Arrays.asList(id));if(l!=null&&emp>0)l.put("destinatarios",destinatariosLembrete(c,emp,id));return l;}
|
||||
private List<Map<String,Object>> destinatariosLembrete(Connection c,long emp,long id)throws Exception{List<Map<String,Object>> ids=rows(c,"SELECT funcionario_id FROM lembrete_destinatarios WHERE lembrete_id=? ORDER BY funcionario_id",Arrays.asList(id));List<Map<String,Object>> out=new ArrayList<>();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone,cargo,status FROM funcionarios WHERE id=? AND empresa_id=?")){for(Map<String,Object> row:ids){ps.setLong(1,((Number)row.get("funcionario_id")).longValue());ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){if(rs.next())out.add(resultMap(rs));}}}return out;}
|
||||
private void listarFuncionariosEmpresa(HttpExchange ex,long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE empresa_id=? ORDER BY id")){ps.setLong(1,empresaId);List<Map<String,Object>> out=new ArrayList<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(resultMap(rs));}HttpResponses.json(ex,200,Map.of("funcionarios",out));}}
|
||||
private void criarFuncionarioEmpresa(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);try(Connection m=masterDs.getMasterConnection()){if(one(m,"SELECT id FROM funcionarios WHERE email=?",Arrays.asList(req(b,"email")))!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}try(PreparedStatement ps=m.prepareStatement("INSERT INTO funcionarios (empresa_id,nome,email,telefone,senha_hash,cargo,status) VALUES (?,?,?,?,?,?,'ativo') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,req(b,"nome"));ps.setString(3,req(b,"email"));ps.setString(4,b.get("telefone"));ps.setString(5,com.seconectai.condocontab.auth.PasswordHasher.hash(req(b,"senha")));ps.setString(6,b.getOrDefault("cargo","funcionario"));try(ResultSet rs=ps.executeQuery()){rs.next();HttpResponses.json(ex,201,Map.of("id",rs.getLong(1),"ok",true));}}}}
|
||||
private void atualizarFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
|
||||
Map<String,String>b=body(ex);
|
||||
try(Connection m=masterDs.getMasterConnection()){
|
||||
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
|
||||
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
|
||||
if("admin_empresa".equals(str(atual.get("cargo")))&&funcId!=solicitante){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser alterada por aqui."));return;}
|
||||
String email=b.get("email");
|
||||
if(email!=null&&!email.isBlank()){
|
||||
Map<String,Object> dono=one(m,"SELECT id FROM funcionarios WHERE email=? AND id<>?",Arrays.asList(email,funcId));
|
||||
if(dono!=null){HttpResponses.json(ex,409,Map.of("erro","email_duplicado","mensagem","Este e-mail ja esta cadastrado."));return;}
|
||||
}
|
||||
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET nome=COALESCE(?,nome), email=COALESCE(?,email), telefone=COALESCE(?,telefone), status=COALESCE(?,status), senha_hash=COALESCE(?,senha_hash) WHERE id=? AND empresa_id=?")){
|
||||
ps.setString(1,b.get("nome"));
|
||||
ps.setString(2,email);
|
||||
ps.setString(3,b.get("telefone"));
|
||||
ps.setString(4,b.get("status"));
|
||||
String senha=b.get("senha");
|
||||
ps.setString(5,senha==null||senha.isBlank()?null:com.seconectai.condocontab.auth.PasswordHasher.hash(senha));
|
||||
ps.setLong(6,funcId);
|
||||
ps.setLong(7,empresaId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
HttpResponses.json(ex,200,Map.of("funcionario",one(m,"SELECT id,empresa_id,nome,email,telefone,cargo,status,criado_em FROM funcionarios WHERE id=?",Arrays.asList(funcId))));
|
||||
}
|
||||
}
|
||||
private void excluirFuncionarioEmpresa(HttpExchange ex,long empresaId,long funcId,long solicitante)throws Exception{
|
||||
if(funcId==solicitante){HttpResponses.json(ex,400,Map.of("erro","auto_exclusao_bloqueada","mensagem","Voce nao pode excluir seu proprio acesso."));return;}
|
||||
try(Connection m=masterDs.getMasterConnection()){
|
||||
Map<String,Object> atual=one(m,"SELECT id,cargo FROM funcionarios WHERE id=? AND empresa_id=?",Arrays.asList(funcId,empresaId));
|
||||
if(atual==null){HttpResponses.json(ex,404,Map.of("erro","funcionario_nao_encontrado"));return;}
|
||||
if("admin_empresa".equals(str(atual.get("cargo")))){HttpResponses.json(ex,400,Map.of("erro","admin_protegido","mensagem","A conta principal nao pode ser excluida."));return;}
|
||||
try(PreparedStatement ps=m.prepareStatement("UPDATE funcionarios SET status='inativo' WHERE id=? AND empresa_id=?")){ps.setLong(1,funcId);ps.setLong(2,empresaId);ps.executeUpdate();}
|
||||
}
|
||||
try(Connection c=tenantDs.getConnection(empresaId);PreparedStatement ps=c.prepareStatement("DELETE FROM funcionario_condominio WHERE funcionario_id=?")){
|
||||
ps.setLong(1,funcId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
HttpResponses.json(ex,200,Map.of("ok",true));
|
||||
}
|
||||
private boolean adminEmpresa(String cargo){return "admin_empresa".equals(cargo);}
|
||||
|
||||
private void conectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,String>b=body(ex);String url=b.getOrDefault("instance_url","");String numero=EvolutionApiClient.onlyDigits(req(b,"numero_whatsapp"));if(numero.length()<10){HttpResponses.json(ex,400,Map.of("erro","numero_whatsapp_invalido","mensagem","Informe o numero com DDD e codigo do pais."));return;}try(Connection m=masterDs.getMasterConnection()){liberarCadastrosEvolutionOrfaos(m,numero);Map<String,Object> existente=one(m,"SELECT id,empresa_id,status FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id DESC LIMIT 1",Arrays.asList(numero));if(existente!=null){HttpResponses.json(ex,409,Map.of("erro","numero_whatsapp_ja_cadastrado","mensagem","Este numero de WhatsApp ja esta cadastrado em uma conexao."));return;}}String instance="empresa"+empresaId+"_"+System.currentTimeMillis();String token=UUID.randomUUID().toString().replace("-","");var criada=evolutionApiClient.criarInstancia(url,instance,token,numero);try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO evolution_instancias (empresa_id,instance_url,instance_token_enc,numero_whatsapp,status) VALUES (?,?,?,?, 'pendente') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,url);ps.setString(3,criada.instanceName()+":"+criada.token());ps.setString(4,numero);try(ResultSet rs=ps.executeQuery()){rs.next();Map<String,Object> resp=new LinkedHashMap<>();resp.put("id",rs.getLong(1));resp.put("instance_name",criada.instanceName());resp.put("status","pendente");resp.put("qrcode_base64",criada.qrcodeBase64());resp.put("mock",criada.mock());HttpResponses.json(ex,201,resp);}}}
|
||||
private void liberarCadastrosEvolutionOrfaos(Connection m,String numero)throws Exception{List<Map<String,Object>> existentes=rows(m,"SELECT id,instance_url,instance_token_enc FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id",Arrays.asList(numero));for(Map<String,Object> inst:existentes){String token=str(inst.get("instance_token_enc"));String instanceName=token.split(":",2)[0];if(instanceName.isBlank())continue;try{evolutionApiClient.estado(str(inst.get("instance_url")),instanceName);}catch(Exception err){if(!evolutionNaoEncontrada(err))throw err;try(PreparedStatement ps=m.prepareStatement("DELETE FROM evolution_instancias WHERE id=?")){ps.setLong(1,((Number)inst.get("id")).longValue());ps.executeUpdate();}}}}
|
||||
private boolean evolutionNaoEncontrada(Exception err){String msg=err.getMessage()==null?"":err.getMessage().toLowerCase(Locale.ROOT);return msg.contains("404")||msg.contains("not found")||msg.contains("instance not found")||msg.contains("nao encontrada")||msg.contains("não encontrada");}
|
||||
private void statusEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,200,Map.of("status","nao_configurada"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("instancia",inst);try{String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);resp.put("evolution",evolutionApiClient.estado(str(inst.get("instance_url")),parts[0]));}catch(Exception ignored){}HttpResponses.json(ex,200,resp);}
|
||||
private void qrcodeEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);Map<String,Object> qr=evolutionApiClient.conectar(str(inst.get("instance_url")),parts[0],str(inst.get("numero_whatsapp")));HttpResponses.json(ex,200,qr);}
|
||||
private void desconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.logout(str(inst.get("instance_url")),parts[0]));}
|
||||
private void reconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map<String,Object> inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.reiniciar(str(inst.get("instance_url")),parts[0]));}
|
||||
private Map<String,Object> evolutionInstancia(long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC,id DESC LIMIT 1")){ps.setLong(1,empresaId);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
|
||||
|
||||
private void listarAssembleiasCalendario(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{
|
||||
String sql=adminEmpresa(cargo)?"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id ORDER BY a.data_hora DESC":"SELECT a.*, co.nome condominio_nome FROM assembleias a JOIN condominios co ON co.id=a.condominio_id JOIN funcionario_condominio fc ON fc.condominio_id=a.condominio_id AND fc.funcionario_id=? ORDER BY a.data_hora DESC";
|
||||
List<Map<String,Object>> out=rows(c,sql,adminEmpresa(cargo)?Arrays.asList():Arrays.asList(fid));
|
||||
for(Map<String,Object>a:out){
|
||||
long aid=((Number)a.get("id")).longValue();
|
||||
Map<String,Object> aviso=one(c,"SELECT id FROM avisos WHERE assembleia_id=? ORDER BY id DESC LIMIT 1",Arrays.asList(aid));
|
||||
if(aviso!=null){long avisoId=((Number)aviso.get("id")).longValue();a.put("aviso_id",avisoId);a.put("destinatarios",destinatariosAviso(c,emp,avisoId));}
|
||||
else a.put("destinatarios",List.of());
|
||||
}
|
||||
HttpResponses.json(ex,200,Map.of("assembleias",out));
|
||||
}
|
||||
private List<Map<String,Object>> destinatariosAviso(Connection c,long emp,long aviso)throws Exception{
|
||||
List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso));
|
||||
List<Map<String,Object>> out=new ArrayList<>();
|
||||
for(Map<String,Object>d:dests){
|
||||
String tipo=str(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> pessoa;
|
||||
if("funcionario".equals(tipo))pessoa=funcionario(emp,did);else if("extra".equals(tipo))pessoa=one(c,"SELECT id,nome,email,telefone FROM destinatarios_extra WHERE id=?",Arrays.asList(did));else pessoa=one(c,"SELECT id,nome,email,telefone FROM contatos WHERE id=?",Arrays.asList(did));
|
||||
if(pessoa!=null){Map<String,Object> item=new LinkedHashMap<>(pessoa);item.put("tipo",tipo);out.add(item);}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
private void criarAviso(HttpExchange ex,Connection c,long emp,long cond,long fid)throws Exception{String raw=rawBody(ex);Map<String,String>b=SimpleJson.parseObject(removeArrayField(removeArrayField(raw,"documentos"),"destinatarios"));boolean w=bool(b.get("enviar_whatsapp"));boolean e=bool(b.get("enviar_email"));List<Destinatario> dests=destinatarios(raw);if((w||e)&&dests.isEmpty()){HttpResponses.json(ex,400,Map.of("erro","destinatario_obrigatorio","mensagem","Selecione ao menos um destinatario para enviar o aviso."));return;}Timestamp dataEnvio=ts(b.get("data_evento"));boolean agendado=dataEnvio!=null&&dataEnvio.after(Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))));if(w&&!funcionalidadeAtiva(emp,"whatsapp_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_whatsapp_inativa","mensagem","Envio por WhatsApp nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque WhatsApp."));return;}if(e&&!funcionalidadeAtiva(emp,"email_envio")){HttpResponses.json(ex,400,Map.of("erro","funcionalidade_email_inativa","mensagem","Envio por e-mail nao esta liberado para esta empresa. Libere a funcionalidade no painel Master ou desmarque E-mail."));return;}if(w&&evolutionAprovada(emp)==null){HttpResponses.json(ex,400,Map.of("erro","instancia_pendente_aprovacao","mensagem","A conexao WhatsApp ainda nao foi aprovada no painel Master."));return;}c.setAutoCommit(false);try{int rec=recorrenciaMeses(b.get("recorrencia_meses"));long aviso=insert(c,"INSERT INTO avisos (condominio_id,assembleia_id,tipo,titulo,mensagem,data_evento,data_expiracao,prioridade,enviar_whatsapp,enviar_email,status_envio,criado_por_funcionario,recorrencia_meses,recorrencia_ativa) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",Arrays.asList(cond,optLong(b.get("assembleia_id")),b.getOrDefault("tipo","aviso"),req(b,"titulo"),req(b,"mensagem"),dataEnvio,ts(b.get("data_expiracao")),b.getOrDefault("prioridade","normal"),w,e,agendado?"agendado":"nao_enviado",fid,rec,rec>0));for(Long d:longArray(raw,"documentos")){insert(c,"INSERT INTO aviso_documentos (aviso_id,documento_id) VALUES (?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d));}for(Destinatario d:dests){insert(c,"INSERT INTO aviso_destinatarios (aviso_id,tipo_destinatario,destinatario_id) VALUES (?,?,?) ON CONFLICT DO NOTHING",Arrays.asList(aviso,d.tipo(),d.id()));}audit(c,fid,"create","avisos",aviso,null,b);if(w||e)enfileirarAviso(c,aviso,dataEnvio);c.commit();detalheAviso(ex,c,cond,aviso);}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
|
||||
private void listarAvisos(HttpExchange ex,Connection c,long cond)throws Exception{HttpResponses.json(ex,200,Map.of("avisos",rows(c,"SELECT * FROM avisos WHERE condominio_id=? ORDER BY data_publicacao DESC,id DESC",Arrays.asList(cond))));}
|
||||
private void detalheAviso(HttpExchange ex,Connection c,long cond,long aviso)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}Map<String,Object> resp=new LinkedHashMap<>();resp.put("aviso",a);resp.put("documentos",rows(c,"SELECT ad.*, d.nome_arquivo, d.caminho_armazenamento FROM aviso_documentos ad JOIN documentos d ON d.id=ad.documento_id WHERE ad.aviso_id=?",Arrays.asList(aviso)));resp.put("destinatarios",rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=? ORDER BY tipo_destinatario,destinatario_id",Arrays.asList(aviso)));HttpResponses.json(ex,200,resp);}
|
||||
private void reenviarAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso)throws Exception{try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='nao_enviado' WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);if(ps.executeUpdate()==0){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}}enviarAviso(c,emp,cond,aviso,false);detalheAviso(ex,c,cond,aviso);}
|
||||
private void excluirAviso(HttpExchange ex,Connection c,long emp,long cond,long aviso,long fid)throws Exception{Map<String,Object> old=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(old==null){HttpResponses.json(ex,404,Map.of("erro","aviso_nao_encontrado"));return;}c.setAutoCommit(false);try{try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_documentos WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM aviso_destinatarios WHERE aviso_id=?")){ps.setLong(1,aviso);ps.executeUpdate();}try(PreparedStatement ps=c.prepareStatement("DELETE FROM avisos WHERE id=? AND condominio_id=?")){ps.setLong(1,aviso);ps.setLong(2,cond);ps.executeUpdate();}audit(c,fid,"delete","avisos",aviso,old,Map.of("ok",true));c.commit();try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("DELETE FROM logs_envio_avisos WHERE empresa_id=? AND condominio_id=? AND aviso_id=?")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.executeUpdate();}HttpResponses.json(ex,200,Map.of("ok",true));}catch(Exception err){c.rollback();throw err;}finally{c.setAutoCommit(true);}}
|
||||
private void enviarAviso(Connection c,long emp,long cond,long aviso,boolean apenasFalhos)throws Exception{Map<String,Object>a=one(c,"SELECT * FROM avisos WHERE id=? AND condominio_id=?",Arrays.asList(aviso,cond));if(a==null)return;boolean w=Boolean.TRUE.equals(a.get("enviar_whatsapp"));boolean e=Boolean.TRUE.equals(a.get("enviar_email"));Map<String,Object> inst=w?evolutionAprovada(emp):null;Map<String,String> smtp=e?smtpConfig():Map.of();List<Map<String,Object>> dests=rows(c,"SELECT * FROM aviso_destinatarios WHERE aviso_id=?",Arrays.asList(aviso));boolean allOk=true;int tentativas=0;if(dests.isEmpty())allOk=false;for(Map<String,Object>d:dests){String tipo=String.valueOf(d.get("tipo_destinatario"));long did=((Number)d.get("destinatario_id")).longValue();Map<String,Object> contato="funcionario".equals(tipo)?funcionario(emp,did):("extra".equals(tipo)?one(c,"SELECT id,nome,telefone,email FROM destinatarios_extra WHERE id=? AND status='ativo'",Arrays.asList(did)):one(c,"SELECT id,nome,telefone,email FROM contatos WHERE id=?",Arrays.asList(did)));if(contato==null){allOk=false;continue;}String nome=String.valueOf(contato.get("nome"));if(w&&deveTentar(emp,aviso,did,"whatsapp",apenasFalhos)){tentativas++;String tel=telefoneWhatsapp(contato.get("telefone"));try{if(tel.length()<12)throw new IllegalArgumentException("telefone_invalido");String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);String resposta=evolutionApiClient.enviarTexto(str(inst.get("instance_url")),parts[0],parts.length>1?parts[1]:"",tel,msg(a));logEnvio(emp,cond,aviso,did,nome,"whatsapp","enviado",limite(resposta));}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"whatsapp","falhou",err.getMessage());}}if(e&&deveTentar(emp,aviso,did,"email",apenasFalhos)){tentativas++;String email=str(contato.get("email"));try{if(!email.contains("@"))throw new IllegalArgumentException("email_invalido");emailClient.enviar(smtp,email,str(a.get("titulo")),msg(a));logEnvio(emp,cond,aviso,did,nome,"email","enviado",null);}catch(Exception err){allOk=false;logEnvio(emp,cond,aviso,did,nome,"email","falhou",err.getMessage());}}}if(allOk&&tentativas>0&&reagendarAvisoRecorrente(c,aviso,a))return;try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio=?, ultimo_envio=CASE WHEN ? THEN now() ELSE ultimo_envio END WHERE id=?")){boolean ok=allOk&&tentativas>0;ps.setString(1,ok?"enviado":"falhou");ps.setBoolean(2,ok);ps.setLong(3,aviso);ps.executeUpdate();}}
|
||||
private boolean reagendarAvisoRecorrente(Connection c,long aviso,Map<String,Object>a)throws SQLException{
|
||||
int meses=a.get("recorrencia_meses")==null?0:((Number)a.get("recorrencia_meses")).intValue();
|
||||
if(!Boolean.TRUE.equals(a.get("recorrencia_ativa"))||meses<=0)return false;
|
||||
Timestamp base=(Timestamp)a.get("data_evento");
|
||||
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
|
||||
LocalDateTime proximo=inicio.plusMonths(meses);
|
||||
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
|
||||
Timestamp prox=Timestamp.valueOf(proximo);
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE avisos SET status_envio='agendado', data_evento=?, ultimo_envio=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,aviso);ps.executeUpdate();}
|
||||
enfileirarAviso(c,aviso,prox);
|
||||
return true;
|
||||
}
|
||||
private boolean avisoRecorrente(Connection c,long aviso)throws SQLException{Map<String,Object>a=one(c,"SELECT recorrencia_ativa,recorrencia_meses FROM avisos WHERE id=?",Arrays.asList(aviso));return a!=null&&Boolean.TRUE.equals(a.get("recorrencia_ativa"))&&a.get("recorrencia_meses")!=null&&((Number)a.get("recorrencia_meses")).intValue()>0;}
|
||||
|
||||
private boolean deveTentar(long emp,long aviso,long contato,String canal,boolean apenasFalhos)throws SQLException{if(!apenasFalhos)return true;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT status FROM logs_envio_avisos WHERE empresa_id=? AND aviso_id=? AND contato_id=? AND canal=? ORDER BY criado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);ps.setLong(2,aviso);ps.setLong(3,contato);ps.setString(4,canal);try(ResultSet rs=ps.executeQuery()){return rs.next()&&"falhou".equals(rs.getString(1));}}}
|
||||
private void logEnvio(long emp,long cond,long aviso,long contato,String nome,String canal,String status,String erro)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO logs_envio_avisos (empresa_id,condominio_id,aviso_id,contato_id,contato_nome,canal,status,erro_detalhe) VALUES (?,?,?,?,?,?,?,?)")){ps.setLong(1,emp);ps.setLong(2,cond);ps.setLong(3,aviso);ps.setLong(4,contato);ps.setString(5,nome);ps.setString(6,canal);ps.setString(7,status);ps.setString(8,erro);ps.executeUpdate();}}
|
||||
private boolean funcionalidadeAtiva(long emp,String chave)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT COALESCE(ef.ativa,false) FROM funcionalidades f LEFT JOIN empresa_funcionalidades ef ON ef.funcionalidade_id=f.id AND ef.empresa_id=? WHERE f.chave=?")){ps.setLong(1,emp);ps.setString(2,chave);try(ResultSet rs=ps.executeQuery()){return rs.next()&&rs.getBoolean(1);}}}
|
||||
private Map<String,Object> evolutionAprovada(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT * FROM evolution_instancias WHERE empresa_id=? AND status='aprovada' ORDER BY aprovado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
|
||||
private Map<String,String> smtpConfig()throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT chave,valor FROM configuracoes_plataforma WHERE chave LIKE 'smtp_%'")){Map<String,String> out=new HashMap<>();try(ResultSet rs=ps.executeQuery()){while(rs.next())out.put(rs.getString(1),rs.getString(2));}return out;}}
|
||||
private Map<String,Object> funcionario(long emp,long id)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,nome,email,telefone FROM funcionarios WHERE id=? AND empresa_id=?")){ps.setLong(1,id);ps.setLong(2,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}}
|
||||
private String telefoneWhatsapp(Object raw){String d=EvolutionApiClient.onlyDigits(str(raw));if((d.length()==10||d.length()==11)&&!d.startsWith("55"))return "55"+d;return d;}
|
||||
private String limite(String v){if(v==null)return null;return v.length()>900?v.substring(0,900):v;}
|
||||
private String msg(Map<String,Object>a){return str(a.get("titulo"))+"\n\n"+str(a.get("mensagem"));}
|
||||
|
||||
private void concluirFilaLembrete(Connection c,long lembreteId)throws SQLException{try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=\'enviado\', locked_at=NULL, atualizado_em=now() WHERE tipo=\'lembrete\' AND referencia_id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}}
|
||||
private void enfileirarAviso(Connection c,long avisoId,Timestamp dataHora)throws SQLException{
|
||||
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
|
||||
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('aviso',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
|
||||
ps.setLong(1,avisoId);
|
||||
ps.setTimestamp(2,agendado);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void enfileirarLembrete(Connection c,long lembreteId,Timestamp dataHora)throws SQLException{
|
||||
Timestamp agendado=dataHora==null?Timestamp.valueOf(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))):dataHora;
|
||||
try(PreparedStatement ps=c.prepareStatement("INSERT INTO fila_envio (tipo,referencia_id,status,agendado_para,tentativas,ultimo_erro,locked_at) VALUES ('lembrete',?,'pendente',?,0,NULL,NULL) ON CONFLICT (tipo,referencia_id) DO UPDATE SET status='pendente', agendado_para=EXCLUDED.agendado_para, ultimo_erro=NULL, locked_at=NULL, atualizado_em=now()")){
|
||||
ps.setLong(1,lembreteId);
|
||||
ps.setTimestamp(2,agendado);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int processarFilaEnvios()throws Exception{
|
||||
int total=0;
|
||||
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
|
||||
try(ResultSet rs=ps.executeQuery()){
|
||||
while(rs.next()){
|
||||
long emp=rs.getLong(1);
|
||||
try(Connection c=tenantDs.getConnection(emp)){
|
||||
total+=processarFilaEmpresa(c,emp);
|
||||
}catch(Exception err){
|
||||
System.err.println("Falha ao processar fila de envio da empresa "+emp+": "+err.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private int processarFilaEmpresa(Connection c,long emp)throws Exception{
|
||||
int total=0;
|
||||
while(true){
|
||||
Map<String,Object> item=proximoItemFila(c);
|
||||
if(item==null)return total;
|
||||
long filaId=((Number)item.get("id")).longValue();
|
||||
long ref=((Number)item.get("referencia_id")).longValue();
|
||||
try{
|
||||
String tipo=str(item.get("tipo"));
|
||||
if("lembrete".equals(tipo)){
|
||||
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,ref);
|
||||
if(lembrete==null||!"ativo".equalsIgnoreCase(str(lembrete.get("status")))){
|
||||
concluirItemFila(c,filaId,"ignorado",null);
|
||||
}else{
|
||||
Map<String,Object> envio=enviarLembreteWhatsApp(emp,ref,lembrete);
|
||||
int enviados=Number.class.isInstance(envio.get("enviados"))?((Number)envio.get("enviados")).intValue():0;
|
||||
int falhas=Number.class.isInstance(envio.get("falhas"))?((Number)envio.get("falhas")).intValue():1;
|
||||
if(enviados>0&&falhas==0){
|
||||
boolean reagendado=finalizarOuReagendarLembrete(c,ref);
|
||||
if(!reagendado)concluirItemFila(c,filaId,"enviado",null);
|
||||
}else{
|
||||
falharItemFila(c,filaId,str(envio.getOrDefault("mensagem","Falha ao enviar lembrete.")));
|
||||
}
|
||||
}
|
||||
}else if("aviso".equals(tipo)){
|
||||
Map<String,Object> aviso=one(c,"SELECT id,condominio_id,status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
|
||||
if(aviso==null||"enviado".equalsIgnoreCase(str(aviso.get("status_envio")))){
|
||||
concluirItemFila(c,filaId,"ignorado",null);
|
||||
}else{
|
||||
long cond=((Number)aviso.get("condominio_id")).longValue();
|
||||
enviarAviso(c,emp,cond,ref,false);
|
||||
Map<String,Object> atualizado=one(c,"SELECT status_envio FROM avisos WHERE id=?",Arrays.asList(ref));
|
||||
if(atualizado!=null&&"enviado".equalsIgnoreCase(str(atualizado.get("status_envio"))))concluirItemFila(c,filaId,"enviado",null);
|
||||
else if(atualizado!=null&&"agendado".equalsIgnoreCase(str(atualizado.get("status_envio")))&&avisoRecorrente(c,ref)){ }
|
||||
else falharItemFila(c,filaId,"Falha ao enviar aviso.");
|
||||
}
|
||||
}else{
|
||||
concluirItemFila(c,filaId,"ignorado","Tipo de mensagem desconhecido: "+tipo);
|
||||
}
|
||||
total++;
|
||||
}catch(Exception err){
|
||||
falharItemFila(c,filaId,err.getMessage());
|
||||
total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String,Object> proximoItemFila(Connection c)throws SQLException{
|
||||
c.setAutoCommit(false);
|
||||
try{
|
||||
Map<String,Object> item=one(c,"SELECT * FROM fila_envio WHERE status='pendente' AND agendado_para <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY agendado_para,id LIMIT 1 FOR UPDATE SKIP LOCKED",Arrays.asList());
|
||||
if(item!=null){
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status='processando', locked_at=now(), tentativas=tentativas+1, atualizado_em=now() WHERE id=?")){
|
||||
ps.setLong(1,((Number)item.get("id")).longValue());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
c.commit();
|
||||
return item;
|
||||
}catch(Exception err){
|
||||
c.rollback();
|
||||
throw err;
|
||||
}finally{
|
||||
c.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void concluirItemFila(Connection c,long filaId,String status,String erro)throws SQLException{
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=?, ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
|
||||
ps.setString(1,status);
|
||||
ps.setString(2,erro);
|
||||
ps.setLong(3,filaId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void falharItemFila(Connection c,long filaId,String erro)throws SQLException{
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE fila_envio SET status=CASE WHEN tentativas>=3 THEN 'falhou' ELSE 'pendente' END, agendado_para=(now() AT TIME ZONE 'America/Sao_Paulo') + interval '1 minute', ultimo_erro=?, locked_at=NULL, atualizado_em=now() WHERE id=?")){
|
||||
ps.setString(1,limite(erro));
|
||||
ps.setLong(2,filaId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int enviarLembretesAgendados()throws Exception{
|
||||
int total=0;
|
||||
try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){
|
||||
try(ResultSet rs=ps.executeQuery()){
|
||||
while(rs.next()){
|
||||
long emp=rs.getLong(1);
|
||||
try(Connection c=tenantDs.getConnection(emp)){
|
||||
List<Map<String,Object>> pendentes=rows(c,"SELECT id FROM lembretes WHERE status='ativo' AND data_hora IS NOT NULL AND data_hora <= (now() AT TIME ZONE 'America/Sao_Paulo') ORDER BY data_hora,id",Arrays.asList());
|
||||
for(Map<String,Object> row:pendentes){
|
||||
long lembreteId=((Number)row.get("id")).longValue();
|
||||
Map<String,Object> lembrete=lembreteComDetalhes(c,emp,lembreteId);
|
||||
if(lembrete==null)continue;
|
||||
Map<String,Object> envio=enviarLembreteWhatsApp(emp,lembreteId,lembrete);
|
||||
int enviados=((Number)envio.getOrDefault("enviados",0)).intValue();
|
||||
int falhas=((Number)envio.getOrDefault("falhas",0)).intValue();
|
||||
if(enviados>0&&falhas==0)finalizarOuReagendarLembrete(c,lembreteId);
|
||||
total++;
|
||||
}
|
||||
}catch(Exception err){
|
||||
System.err.println("Falha ao enviar lembretes agendados da empresa "+emp+": "+err.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean finalizarOuReagendarLembrete(Connection c,long lembreteId)throws SQLException{
|
||||
Map<String,Object> l=one(c,"SELECT recorrencia_ativa,recorrencia_meses,data_hora FROM lembretes WHERE id=?",Arrays.asList(lembreteId));
|
||||
int meses=l==null||l.get("recorrencia_meses")==null?0:((Number)l.get("recorrencia_meses")).intValue();
|
||||
boolean recorrente=l!=null&&Boolean.TRUE.equals(l.get("recorrencia_ativa"))&&meses>0;
|
||||
if(recorrente){
|
||||
Timestamp base=(Timestamp)l.get("data_hora");
|
||||
LocalDateTime inicio=base==null?LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo")):base.toLocalDateTime();
|
||||
LocalDateTime proximo=inicio.plusMonths(meses);
|
||||
while(!proximo.isAfter(LocalDateTime.now(java.time.ZoneId.of("America/Sao_Paulo"))))proximo=proximo.plusMonths(meses);
|
||||
Timestamp prox=Timestamp.valueOf(proximo);
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET data_hora=?, ultimo_envio=now(), status='ativo', atualizado_em=now() WHERE id=?")){ps.setTimestamp(1,prox);ps.setLong(2,lembreteId);ps.executeUpdate();}
|
||||
enfileirarLembrete(c,lembreteId,prox);
|
||||
return true;
|
||||
}
|
||||
try(PreparedStatement ps=c.prepareStatement("UPDATE lembretes SET status='concluido', ultimo_envio=now(), atualizado_em=now() WHERE id=?")){ps.setLong(1,lembreteId);ps.executeUpdate();}
|
||||
concluirFilaLembrete(c,lembreteId);
|
||||
return false;
|
||||
}
|
||||
|
||||
public int enviarAvisosAgendados()throws Exception{int total=0;try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT e.id FROM empresas e JOIN pg_database d ON d.datname=e.database_name WHERE e.status='ativa' ORDER BY e.id")){try(ResultSet rs=ps.executeQuery()){while(rs.next()){long emp=rs.getLong(1);try(Connection c=tenantDs.getConnection(emp)){List<Map<String,Object>> pendentes=rows(c,"SELECT id,condominio_id FROM avisos WHERE (enviar_whatsapp=true OR enviar_email=true) AND status_envio IN ('nao_enviado','agendado') AND (data_evento IS NULL OR data_evento<=(now() AT TIME ZONE 'America/Sao_Paulo')) ORDER BY data_evento NULLS FIRST,id",Arrays.asList());for(Map<String,Object>a:pendentes){enviarAviso(c,emp,((Number)a.get("condominio_id")).longValue(),((Number)a.get("id")).longValue(),false);total++;}}catch(Exception err){System.err.println("Falha ao enviar avisos agendados da empresa "+emp+": "+err.getMessage());}}}}return total;}
|
||||
|
||||
private void execWrite(HttpExchange ex,Connection c,String sql,List<?>params,long fid,String tabela,long rid)throws Exception{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);ps.executeUpdate();}audit(c,fid,"create",tabela,rid,null,Map.of("ok",true));HttpResponses.json(ex,200,Map.of("ok",true));}
|
||||
private void listar(HttpExchange ex,Connection c,String sql,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList())));}
|
||||
private void listarParam(HttpExchange ex,Connection c,String sql,long id,String key)throws Exception{HttpResponses.json(ex,200,Map.of(key,rows(c,sql,Arrays.asList(id))));}
|
||||
private long insert(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql,Statement.RETURN_GENERATED_KEYS)){bind(ps,params);ps.executeUpdate();try(ResultSet rs=ps.getGeneratedKeys()){rs.next();return rs.getLong(1);}}}
|
||||
private Map<String,Object> one(Connection c,String sql,List<?>params)throws SQLException{List<Map<String,Object>> r=rows(c,sql,params);return r.isEmpty()?null:r.get(0);} private List<Map<String,Object>> rows(Connection c,String sql,List<?>params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){List<Map<String,Object>> out=new ArrayList<>();ResultSetMetaData md=rs.getMetaData();while(rs.next()){Map<String,Object> m=new LinkedHashMap<>();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));out.add(m);}return out;}}}
|
||||
private long count(Connection c,String sql,long id)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,id);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
|
||||
private long count(Connection c,String sql,long a,long b)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setLong(1,a);ps.setLong(2,b);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}}
|
||||
private Map<String,Object> resultMap(ResultSet rs)throws SQLException{Map<String,Object> m=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;}
|
||||
private void audit(Connection c,long fid,String acao,String tabela,long rid,Object old,Object neu)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO log_auditoria (funcionario_id,acao,tabela_afetada,registro_id,dados_anteriores,dados_novos) VALUES (?,?,?,?,?::jsonb,?::jsonb)")){ps.setLong(1,fid);ps.setString(2,acao);ps.setString(3,tabela);ps.setLong(4,rid);ps.setString(5,old==null?null:Json.write(old));ps.setString(6,neu==null?null:Json.write(neu));ps.executeUpdate();}}
|
||||
private void bind(PreparedStatement ps,List<?>params)throws SQLException{for(int i=0;i<params.size();i++){Object v=params.get(i); if(v==null)ps.setObject(i+1,null); else ps.setObject(i+1,v);}}
|
||||
private Map<String,String> body(HttpExchange ex)throws IOException{return SimpleJson.parseObject(rawBody(ex));} private String rawBody(HttpExchange ex)throws IOException{return new String(ex.getRequestBody().readAllBytes(),StandardCharsets.UTF_8);} private String req(Map<String,String>m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;} private Object nullable(String v){return v==null||v.isBlank()?null:v;} private Long optLong(String v){return v==null||v.isBlank()?null:Long.parseLong(v);} private long id(String s){return Long.parseLong(s);} private void setDate(PreparedStatement ps,int i,String v)throws SQLException{if(v==null||v.isBlank())ps.setNull(i,Types.DATE);else ps.setDate(i,java.sql.Date.valueOf(LocalDate.parse(v)));}
|
||||
private Timestamp ts(String v){
|
||||
if(v==null||v.isBlank())return null;
|
||||
String x=v.trim().replace('T',' ').replace(",", " ").replaceAll("\\s+", " ");
|
||||
try{
|
||||
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"))x=x+":00";
|
||||
if(x.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"))return Timestamp.valueOf(x);
|
||||
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"));return Timestamp.valueOf(dt);}
|
||||
if(x.matches("\\d{2}/\\d{2}/\\d{4} \\d{2}:\\d{2}:\\d{2}")){LocalDateTime dt=LocalDateTime.parse(x,java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"));return Timestamp.valueOf(dt);}
|
||||
}catch(Exception ignored){}
|
||||
throw new IllegalArgumentException("Data/hora invalida. Use dd/mm/aaaa hh:mm.");
|
||||
} private int recorrenciaMeses(String v){if(v==null||v.isBlank())return 0;int n=Integer.parseInt(v);if(n==0||n==1||n==3||n==12)return n;throw new IllegalArgumentException("Recorrencia invalida. Use 0, 1, 3 ou 12 meses.");} private boolean bool(String v){return "true".equalsIgnoreCase(v)||"1".equals(v)||"sim".equalsIgnoreCase(v);} private String str(Object v){return v==null?"":String.valueOf(v);} private String cnpj(String v){String d=v==null?"":v.replaceAll("\\D","");if(d.length()!=14)throw new IllegalArgumentException("CNPJ invalido. Informe 14 digitos.");return d;}
|
||||
private List<Long> longArray(String raw,String key){String a=arrayBody(raw,key);if(a.isBlank())return List.of();List<Long> out=new ArrayList<>();for(String x:a.split(",")){String v=x.trim().replace("\"","");if(!v.isBlank())out.add(Long.parseLong(v));}return out;}
|
||||
private List<Destinatario> destinatarios(String raw){String a=arrayBody(raw,"destinatarios");if(a.isBlank())return List.of();List<Destinatario> out=new ArrayList<>();for(String obj:a.split("\\},\\s*\\{")){String x=obj.replace("{","").replace("}","");String tipo=null;Long id=null;for(String kv:x.split(",")){String[]p=kv.split(":",2);if(p.length<2)continue;String k=p[0].trim().replace("\"","");String v=p[1].trim().replace("\"","");if("tipo".equals(k)||"tipo_destinatario".equals(k))tipo=v;if("id".equals(k)||"destinatario_id".equals(k))id=Long.parseLong(v);}if(tipo!=null&&id!=null)out.add(new Destinatario(tipo,id));}return out;}
|
||||
private String arrayBody(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return"";int s=raw.indexOf('[',p);if(s<0)return"";int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(s+1,i).trim();}return"";}
|
||||
private String removeArrayField(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return raw;int s=raw.indexOf('[',p);if(s<0)return raw;int depth=0;for(int i=s;i<raw.length();i++){char ch=raw.charAt(i);if(ch=='[')depth++;if(ch==']'&&--depth==0)return raw.substring(0,s)+"\"\""+raw.substring(i+1);}return raw;} record Destinatario(String tipo,long id){}
|
||||
private Map<String,String> query(HttpExchange ex){Map<String,String>q=new HashMap<>();String raw=ex.getRequestURI().getRawQuery();if(raw==null)return q;for(String part:raw.split("&")){String[]kv=part.split("=",2);q.put(URLDecoder.decode(kv[0],StandardCharsets.UTF_8),kv.length>1?URLDecoder.decode(kv[1],StandardCharsets.UTF_8):"");}return q;}
|
||||
private String databaseName(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT database_name FROM empresas WHERE id=?")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getString(1);}}}
|
||||
private String ext(String name){int p=name.lastIndexOf('.');return p<0?"":name.substring(p+1).toLowerCase();}
|
||||
private Multipart parseMultipart(HttpExchange ex)throws IOException{String ct=ex.getRequestHeaders().getFirst("Content-Type");if(ct==null||!ct.contains("boundary="))throw new IllegalArgumentException("multipart/form-data esperado");String boundary="--"+ct.substring(ct.indexOf("boundary=")+9);byte[] body=ex.getRequestBody().readAllBytes();String text=new String(body,StandardCharsets.ISO_8859_1);String filename=null,tipo=null;byte[] fileBytes=null;for(String part:text.split(java.util.regex.Pattern.quote(boundary))){if(!part.contains("Content-Disposition"))continue;int hEnd=part.indexOf("\r\n\r\n");if(hEnd<0)continue;String headers=part.substring(0,hEnd);String content=part.substring(hEnd+4);if(content.endsWith("\r\n"))content=content.substring(0,content.length()-2);if(headers.contains("name=\"tipo\""))tipo=content.trim();if(headers.contains("name=\"arquivo\"")){int fp=headers.indexOf("filename=\"");if(fp>=0){int fe=headers.indexOf('"',fp+10);filename=headers.substring(fp+10,fe);}fileBytes=content.getBytes(StandardCharsets.ISO_8859_1);}}if(filename==null||fileBytes==null)throw new IllegalArgumentException("arquivo obrigatorio");return new Multipart(filename,tipo,fileBytes);} record Multipart(String filename,String tipo,byte[]bytes){}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.seconectai.condocontab.tenant.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record Condominio(Long id, String nome, String cnpj, String endereco, LocalDateTime criadoEm) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.seconectai.condocontab.tenant.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record Contato(Long id, String nome, String telefone, String email, LocalDateTime criadoEm) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.seconectai.condocontab.tenant.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ContatoCondominio(
|
||||
Long contatoId,
|
||||
Long condominioId,
|
||||
String tipo,
|
||||
LocalDate dataInicioMandato,
|
||||
LocalDate dataFimMandato,
|
||||
String status,
|
||||
String contatoNome,
|
||||
String contatoTelefone,
|
||||
String contatoEmail
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.seconectai.condocontab.tenant.provisioning;
|
||||
|
||||
import com.seconectai.condocontab.config.AppConfig;
|
||||
import com.seconectai.condocontab.db.MigrationRunner;
|
||||
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class TenantProvisioner {
|
||||
private final AppConfig config;
|
||||
|
||||
public TenantProvisioner(AppConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public ResultadoProvisionamento provisionar(String nomeEmpresa, String slug) {
|
||||
String databaseName;
|
||||
try {
|
||||
databaseName = databaseNameFromSlug(slug);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResultadoProvisionamento.falha(null, e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
criarDatabaseSeNaoExistir(databaseName);
|
||||
try (var tenantConn = DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword())) {
|
||||
MigrationRunner.runMigrations(tenantConn, "migrations/tenant");
|
||||
}
|
||||
System.out.println("Tenant provisionado para " + nomeEmpresa + ": " + databaseName);
|
||||
return ResultadoProvisionamento.sucesso(databaseName);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Falha ao provisionar tenant " + databaseName + ": " + e.getMessage());
|
||||
return ResultadoProvisionamento.falha(databaseName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public String databaseNameFromSlug(String slug) {
|
||||
if (slug == null || slug.isBlank()) {
|
||||
throw new IllegalArgumentException("Slug obrigatorio para provisionamento do tenant");
|
||||
}
|
||||
if (!slug.matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("Slug invalido: use apenas letras minusculas, numeros, hifen ou underscore");
|
||||
}
|
||||
String normalized = slug.replace('-', '_');
|
||||
if (!normalized.matches("[a-z0-9_]+")) {
|
||||
throw new IllegalArgumentException("Slug invalido apos normalizacao");
|
||||
}
|
||||
return "db_empresa_" + normalized;
|
||||
}
|
||||
|
||||
private void criarDatabaseSeNaoExistir(String databaseName) throws SQLException {
|
||||
try (var conn = DriverManager.getConnection(config.postgresJdbcUrl(), config.dbUser(), config.dbPassword())) {
|
||||
conn.setAutoCommit(true);
|
||||
if (databaseExiste(conn.createStatement(), databaseName)) {
|
||||
return;
|
||||
}
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE DATABASE " + quoteIdentifier(databaseName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean databaseExiste(Statement stmt, String databaseName) throws SQLException {
|
||||
try (var rs = stmt.executeQuery("SELECT 1 FROM pg_database WHERE datname = '" + databaseName + "'")) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
|
||||
private String quoteIdentifier(String identifier) {
|
||||
return "\"" + identifier.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
public record ResultadoProvisionamento(boolean sucesso, String databaseName, String erro) {
|
||||
public static ResultadoProvisionamento sucesso(String databaseName) {
|
||||
return new ResultadoProvisionamento(true, databaseName, null);
|
||||
}
|
||||
|
||||
public static ResultadoProvisionamento falha(String databaseName, String erro) {
|
||||
return new ResultadoProvisionamento(false, databaseName, erro);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.seconectai.condocontab.util;
|
||||
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
public final class Json {
|
||||
private Json() {
|
||||
}
|
||||
|
||||
public static String write(Object value) {
|
||||
return value(value);
|
||||
}
|
||||
|
||||
private static String value(Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
if (value instanceof TemporalAccessor) {
|
||||
return quote(String.valueOf(value));
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
StringBuilder json = new StringBuilder("{");
|
||||
Iterator<? extends Map.Entry<?, ?>> iterator = map.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<?, ?> entry = iterator.next();
|
||||
json.append(quote(String.valueOf(entry.getKey()))).append(":").append(value(entry.getValue()));
|
||||
if (iterator.hasNext()) {
|
||||
json.append(",");
|
||||
}
|
||||
}
|
||||
return json.append("}").toString();
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
StringBuilder json = new StringBuilder("[");
|
||||
Iterator<?> iterator = iterable.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
json.append(value(iterator.next()));
|
||||
if (iterator.hasNext()) {
|
||||
json.append(",");
|
||||
}
|
||||
}
|
||||
return json.append("]").toString();
|
||||
}
|
||||
return quote(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static String quote(String text) {
|
||||
StringBuilder out = new StringBuilder("\"");
|
||||
for (char c : text.toCharArray()) {
|
||||
switch (c) {
|
||||
case '"' -> out.append("\\\"");
|
||||
case '\\' -> out.append("\\\\");
|
||||
case '\n' -> out.append("\\n");
|
||||
case '\r' -> out.append("\\r");
|
||||
case '\t' -> out.append("\\t");
|
||||
default -> out.append(c);
|
||||
}
|
||||
}
|
||||
return out.append("\"").toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.seconectai.condocontab.util;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class SimpleJson {
|
||||
private SimpleJson() {
|
||||
}
|
||||
|
||||
public static Map<String, String> parseObject(String json) {
|
||||
Parser parser = new Parser(json);
|
||||
return parser.parseObject();
|
||||
}
|
||||
|
||||
private static final class Parser {
|
||||
private final String text;
|
||||
private int pos;
|
||||
|
||||
private Parser(String text) {
|
||||
this.text = text == null ? "" : text;
|
||||
}
|
||||
|
||||
private Map<String, String> parseObject() {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
skipWs();
|
||||
expect('{');
|
||||
skipWs();
|
||||
if (peek() == '}') {
|
||||
pos++;
|
||||
return values;
|
||||
}
|
||||
while (true) {
|
||||
skipWs();
|
||||
String key = parseString();
|
||||
skipWs();
|
||||
expect(':');
|
||||
skipWs();
|
||||
values.put(key, parseValueAsString());
|
||||
skipWs();
|
||||
char c = peek();
|
||||
if (c == ',') {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
if (c == '}') {
|
||||
pos++;
|
||||
return values;
|
||||
}
|
||||
throw new IllegalArgumentException("JSON invalido na posicao " + pos);
|
||||
}
|
||||
}
|
||||
|
||||
private String parseValueAsString() {
|
||||
char c = peek();
|
||||
if (c == '"') {
|
||||
return parseString();
|
||||
}
|
||||
int start = pos;
|
||||
while (pos < text.length() && ",}".indexOf(text.charAt(pos)) < 0) {
|
||||
pos++;
|
||||
}
|
||||
return text.substring(start, pos).trim();
|
||||
}
|
||||
|
||||
private String parseString() {
|
||||
expect('"');
|
||||
StringBuilder out = new StringBuilder();
|
||||
while (pos < text.length()) {
|
||||
char c = text.charAt(pos++);
|
||||
if (c == '"') {
|
||||
return out.toString();
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (pos >= text.length()) {
|
||||
throw new IllegalArgumentException("Escape JSON invalido");
|
||||
}
|
||||
char escaped = text.charAt(pos++);
|
||||
switch (escaped) {
|
||||
case '"' -> out.append('"');
|
||||
case '\\' -> out.append('\\');
|
||||
case '/' -> out.append('/');
|
||||
case 'b' -> out.append('\b');
|
||||
case 'f' -> out.append('\f');
|
||||
case 'n' -> out.append('\n');
|
||||
case 'r' -> out.append('\r');
|
||||
case 't' -> out.append('\t');
|
||||
default -> throw new IllegalArgumentException("Escape JSON nao suportado: " + escaped);
|
||||
}
|
||||
} else {
|
||||
out.append(c);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("String JSON nao finalizada");
|
||||
}
|
||||
|
||||
private void expect(char expected) {
|
||||
if (peek() != expected) {
|
||||
throw new IllegalArgumentException("Esperado '" + expected + "' na posicao " + pos);
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
private char peek() {
|
||||
if (pos >= text.length()) {
|
||||
return '\0';
|
||||
}
|
||||
return text.charAt(pos);
|
||||
}
|
||||
|
||||
private void skipWs() {
|
||||
while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE empresas (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(150) NOT NULL,
|
||||
cnpj VARCHAR(18) UNIQUE,
|
||||
slug VARCHAR(80) NOT NULL UNIQUE,
|
||||
database_name VARCHAR(120) NOT NULL UNIQUE,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'ativa',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE funcionarios (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
nome VARCHAR(150) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
senha_hash VARCHAR(255) NOT NULL,
|
||||
cargo VARCHAR(60),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_funcionarios_empresa ON funcionarios(empresa_id);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
key_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
descricao VARCHAR(255),
|
||||
ativa BOOLEAN NOT NULL DEFAULT true,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
revogada_em TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_keys_empresa ON api_keys(empresa_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE funcionalidades (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chave VARCHAR(80) NOT NULL UNIQUE,
|
||||
nome VARCHAR(120) NOT NULL,
|
||||
descricao TEXT
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE empresa_funcionalidades (
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
funcionalidade_id BIGINT NOT NULL REFERENCES funcionalidades(id),
|
||||
ativa BOOLEAN NOT NULL DEFAULT true,
|
||||
liberado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
liberado_por BIGINT REFERENCES funcionarios(id),
|
||||
PRIMARY KEY (empresa_id, funcionalidade_id)
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE evolution_instancias (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
instance_url VARCHAR(255) NOT NULL,
|
||||
instance_token_enc TEXT,
|
||||
numero_whatsapp VARCHAR(20),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
|
||||
solicitado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
aprovado_em TIMESTAMP,
|
||||
aprovado_por BIGINT REFERENCES funcionarios(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_evolution_empresa ON evolution_instancias(empresa_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE configuracoes_plataforma (
|
||||
chave VARCHAR(100) PRIMARY KEY,
|
||||
valor TEXT,
|
||||
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE planos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(120) NOT NULL,
|
||||
valor_mensal_centavos BIGINT NOT NULL,
|
||||
descricao TEXT,
|
||||
ativo BOOLEAN NOT NULL DEFAULT true,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE plano_funcionalidades (
|
||||
plano_id BIGINT NOT NULL REFERENCES planos(id),
|
||||
funcionalidade_id BIGINT NOT NULL REFERENCES funcionalidades(id),
|
||||
PRIMARY KEY (plano_id, funcionalidade_id)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE assinaturas (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
plano_id BIGINT NOT NULL REFERENCES planos(id),
|
||||
valor_mensal_centavos BIGINT NOT NULL,
|
||||
dia_vencimento INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ativa',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assinaturas_empresa ON assinaturas(empresa_id);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE periodos_uso (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
mes_referencia DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
|
||||
data_liberacao TIMESTAMP,
|
||||
data_bloqueio TIMESTAMP,
|
||||
UNIQUE (empresa_id, mes_referencia)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_periodos_uso_empresa ON periodos_uso(empresa_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE pagamentos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
periodo_uso_id BIGINT NOT NULL REFERENCES periodos_uso(id),
|
||||
valor_centavos BIGINT NOT NULL,
|
||||
abacatepay_charge_id VARCHAR(120) UNIQUE,
|
||||
qr_code_payload TEXT,
|
||||
qr_code_base64 TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
pago_em TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pagamentos_empresa ON pagamentos(empresa_id);
|
||||
CREATE INDEX idx_pagamentos_periodo ON pagamentos(periodo_uso_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE logs_envio_avisos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
condominio_id BIGINT,
|
||||
aviso_id BIGINT,
|
||||
contato_id BIGINT,
|
||||
contato_nome VARCHAR(150),
|
||||
canal VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
erro_detalhe TEXT,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_logs_envio_empresa ON logs_envio_avisos(empresa_id, status);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE lembretes_cobranca (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
empresa_id BIGINT NOT NULL REFERENCES empresas(id),
|
||||
periodo_uso_id BIGINT NOT NULL REFERENCES periodos_uso(id),
|
||||
canal VARCHAR(20) NOT NULL,
|
||||
enviado_por BIGINT REFERENCES funcionarios(id),
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
INSERT INTO funcionalidades (chave, nome, descricao) VALUES
|
||||
('condominios_crud', 'Cadastro de condomínios', 'Criar, listar e editar condomínios do escritório.'),
|
||||
('contatos_crud', 'Cadastro de contatos', 'Criar e manter síndicos, subsíndicos e conselheiros.'),
|
||||
('contatos_vinculos', 'Vínculos de contatos', 'Vincular contatos a condomínios com papéis e mandatos.'),
|
||||
('funcionarios_vinculos', 'Funcionários vinculados', 'Vincular funcionários do escritório aos condomínios atendidos.'),
|
||||
('espacos_comuns_crud', 'Espaços comuns', 'Cadastrar áreas comuns e regras básicas de utilização.'),
|
||||
('assembleias_crud', 'Assembleias', 'Cadastrar assembleias ordinárias e extraordinárias.'),
|
||||
('documentos_upload', 'Upload de documentos', 'Arquivar documentos dos condomínios.'),
|
||||
('documentos_download', 'Download de documentos', 'Baixar documentos arquivados.'),
|
||||
('avisos_crud', 'Avisos e alertas', 'Criar avisos e alertas vinculados a condomínios.'),
|
||||
('avisos_documentos', 'Anexos em avisos', 'Anexar documentos já arquivados aos avisos.'),
|
||||
('avisos_destinatarios', 'Destinatários de avisos', 'Selecionar contatos e funcionários como destinatários.'),
|
||||
('whatsapp_envio', 'Envio por WhatsApp', 'Enviar avisos pela Evolution API.'),
|
||||
('email_envio', 'Envio por e-mail', 'Enviar avisos por e-mail SMTP.'),
|
||||
('financeiro_cliente', 'Financeiro do escritório', 'Consultar período de uso e gerar cobrança mensal.'),
|
||||
('abacatepay_pix', 'PIX AbacatePay', 'Gerar cobranças PIX via AbacatePay.'),
|
||||
('api_externa', 'API externa', 'Permitir acesso externo via API Key.'),
|
||||
('dashboard_master', 'Dashboard master', 'Acessar painel master de clientes e planos.'),
|
||||
('dashboard_cliente', 'Dashboard cliente', 'Acessar painel do escritório.'),
|
||||
('planos_crud', 'Planos', 'Criar e editar planos comerciais.'),
|
||||
('funcionalidades_toggle', 'Controle de funcionalidades', 'Ativar ou desativar funcionalidades por empresa.'),
|
||||
('logs_envio', 'Logs de envio', 'Consultar logs de WhatsApp e e-mail.'),
|
||||
('lembretes_cobranca', 'Lembretes de cobrança', 'Registrar lembretes de cobrança para clientes.'),
|
||||
('auditoria', 'Auditoria', 'Registrar ações de escrita no tenant.'),
|
||||
('configuracoes_integracao', 'Configurações de integração', 'Gerenciar Evolution API e SMTP.'),
|
||||
('templates_avisos', 'Templates de avisos', 'Criar modelos reutilizáveis de mensagens.'),
|
||||
('avisos_recorrentes', 'Avisos recorrentes', 'Agendar avisos recorrentes.'),
|
||||
('webhooks_externos', 'Webhooks externos', 'Publicar eventos para sistemas externos.'),
|
||||
('busca_avancada', 'Busca avançada', 'Buscar dados com filtros avançados.'),
|
||||
('exportacao_dados', 'Exportação de dados', 'Exportar cadastros e relatórios.'),
|
||||
('relatorios_gerenciais', 'Relatórios gerenciais', 'Gerar relatórios consolidados para o escritório.')
|
||||
ON CONFLICT (chave) DO NOTHING;
|
||||
@@ -0,0 +1,16 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM evolution_instancias
|
||||
WHERE numero_whatsapp IS NOT NULL
|
||||
GROUP BY numero_whatsapp
|
||||
HAVING count(*) > 1
|
||||
) THEN
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_evolution_numero_whatsapp
|
||||
ON evolution_instancias (numero_whatsapp)
|
||||
WHERE numero_whatsapp IS NOT NULL;
|
||||
ELSE
|
||||
RAISE NOTICE 'Indice unico ux_evolution_numero_whatsapp nao criado: existem numeros duplicados para revisar manualmente.';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE funcionarios ADD COLUMN IF NOT EXISTS telefone VARCHAR(30);
|
||||
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_inicio DATE;
|
||||
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_fim DATE;
|
||||
ALTER TABLE periodos_uso ADD COLUMN IF NOT EXISTS data_vencimento DATE;
|
||||
|
||||
UPDATE periodos_uso pu
|
||||
SET data_inicio = COALESCE(data_inicio, mes_referencia),
|
||||
data_fim = COALESCE(data_fim, (date_trunc('month', mes_referencia)::date + interval '1 month - 1 day')::date),
|
||||
data_vencimento = COALESCE(data_vencimento, (date_trunc('month', mes_referencia)::date + interval '1 month' + ((COALESCE(a.dia_vencimento, 7) - 1) || ' days')::interval)::date)
|
||||
FROM assinaturas a
|
||||
WHERE a.empresa_id = pu.empresa_id
|
||||
AND a.status = 'ativa';
|
||||
|
||||
UPDATE periodos_uso
|
||||
SET data_inicio = COALESCE(data_inicio, mes_referencia),
|
||||
data_fim = COALESCE(data_fim, (date_trunc('month', mes_referencia)::date + interval '1 month - 1 day')::date),
|
||||
data_vencimento = COALESCE(data_vencimento, (date_trunc('month', mes_referencia)::date + interval '1 month 6 days')::date);
|
||||
|
||||
ALTER TABLE periodos_uso ALTER COLUMN data_inicio SET NOT NULL;
|
||||
ALTER TABLE periodos_uso ALTER COLUMN data_fim SET NOT NULL;
|
||||
ALTER TABLE periodos_uso ALTER COLUMN data_vencimento SET NOT NULL;
|
||||
|
||||
UPDATE assinaturas SET dia_vencimento = 7 WHERE dia_vencimento IS NULL OR dia_vencimento = 10;
|
||||
|
||||
INSERT INTO periodos_uso (empresa_id, mes_referencia, data_inicio, data_fim, data_vencimento, status)
|
||||
SELECT e.id,
|
||||
date_trunc('month', current_date - interval '1 month')::date,
|
||||
date_trunc('month', current_date - interval '1 month')::date,
|
||||
(date_trunc('month', current_date)::date - interval '1 day')::date,
|
||||
(date_trunc('month', current_date)::date + ((COALESCE(a.dia_vencimento, 7) - 1) || ' days')::interval)::date,
|
||||
'pendente'
|
||||
FROM empresas e
|
||||
JOIN assinaturas a ON a.empresa_id = e.id AND a.status = 'ativa'
|
||||
WHERE e.status <> 'excluida'
|
||||
ON CONFLICT (empresa_id, mes_referencia) DO UPDATE
|
||||
SET data_inicio = EXCLUDED.data_inicio,
|
||||
data_fim = EXCLUDED.data_fim,
|
||||
data_vencimento = EXCLUDED.data_vencimento;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE condominios (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(150) NOT NULL,
|
||||
cnpj VARCHAR(18) UNIQUE,
|
||||
endereco VARCHAR(255),
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE contatos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(150) NOT NULL,
|
||||
telefone VARCHAR(20),
|
||||
email VARCHAR(150),
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE contato_condominio (
|
||||
contato_id BIGINT NOT NULL REFERENCES contatos(id),
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
data_inicio_mandato DATE,
|
||||
data_fim_mandato DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
|
||||
PRIMARY KEY (contato_id, condominio_id, tipo)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE funcionario_condominio (
|
||||
funcionario_id BIGINT NOT NULL,
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
PRIMARY KEY (funcionario_id, condominio_id)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE espacos_comuns (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
nome VARCHAR(100) NOT NULL,
|
||||
descricao TEXT,
|
||||
valor_utilizacao_centavos BIGINT,
|
||||
indice_correcao VARCHAR(30),
|
||||
data_ultimo_reajuste DATE,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE assembleias (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
data_hora TIMESTAMP NOT NULL,
|
||||
local VARCHAR(150),
|
||||
pauta TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'agendada',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE documentos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
tipo VARCHAR(40),
|
||||
nome_arquivo VARCHAR(255) NOT NULL,
|
||||
caminho_armazenamento VARCHAR(500) NOT NULL,
|
||||
versao INT NOT NULL DEFAULT 1,
|
||||
enviado_por BIGINT,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE avisos (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
condominio_id BIGINT NOT NULL REFERENCES condominios(id),
|
||||
assembleia_id BIGINT REFERENCES assembleias(id),
|
||||
tipo VARCHAR(20) NOT NULL DEFAULT 'aviso',
|
||||
titulo VARCHAR(150) NOT NULL,
|
||||
mensagem TEXT NOT NULL,
|
||||
data_evento TIMESTAMP,
|
||||
data_publicacao TIMESTAMP NOT NULL DEFAULT now(),
|
||||
data_expiracao TIMESTAMP,
|
||||
prioridade VARCHAR(10) DEFAULT 'normal',
|
||||
enviar_whatsapp BOOLEAN NOT NULL DEFAULT false,
|
||||
enviar_email BOOLEAN NOT NULL DEFAULT false,
|
||||
status_envio VARCHAR(20) NOT NULL DEFAULT 'nao_enviado',
|
||||
criado_por_funcionario BIGINT,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE aviso_documentos (
|
||||
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
|
||||
documento_id BIGINT NOT NULL REFERENCES documentos(id),
|
||||
PRIMARY KEY (aviso_id, documento_id)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE aviso_destinatarios (
|
||||
aviso_id BIGINT NOT NULL REFERENCES avisos(id),
|
||||
tipo_destinatario VARCHAR(20) NOT NULL,
|
||||
destinatario_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (aviso_id, tipo_destinatario, destinatario_id)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE log_auditoria (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
funcionario_id BIGINT,
|
||||
acao VARCHAR(20) NOT NULL,
|
||||
tabela_afetada VARCHAR(60) NOT NULL,
|
||||
registro_id BIGINT,
|
||||
dados_anteriores JSONB,
|
||||
dados_novos JSONB,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE INDEX idx_contato_condominio_condominio ON contato_condominio(condominio_id);
|
||||
CREATE INDEX idx_contato_condominio_contato ON contato_condominio(contato_id);
|
||||
CREATE INDEX idx_funcionario_condominio_condominio ON funcionario_condominio(condominio_id);
|
||||
CREATE INDEX idx_espacos_condominio ON espacos_comuns(condominio_id);
|
||||
CREATE INDEX idx_assembleias_condominio ON assembleias(condominio_id, data_hora);
|
||||
CREATE INDEX idx_documentos_condominio ON documentos(condominio_id);
|
||||
CREATE INDEX idx_avisos_condominio ON avisos(condominio_id, data_publicacao);
|
||||
CREATE INDEX idx_avisos_assembleia ON avisos(assembleia_id);
|
||||
CREATE INDEX idx_aviso_destinatarios_aviso ON aviso_destinatarios(aviso_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE condominios
|
||||
ADD CONSTRAINT condominios_cnpj_obrigatorio CHECK (cnpj IS NOT NULL AND btrim(cnpj) <> '') NOT VALID;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE lembretes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
titulo VARCHAR(160) NOT NULL,
|
||||
mensagem TEXT NOT NULL,
|
||||
data_hora TIMESTAMP,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'ativo',
|
||||
criado_por_funcionario BIGINT NOT NULL,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE lembrete_destinatarios (
|
||||
lembrete_id BIGINT NOT NULL REFERENCES lembretes(id) ON DELETE CASCADE,
|
||||
funcionario_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (lembrete_id, funcionario_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lembretes_data_hora ON lembretes(data_hora);
|
||||
CREATE INDEX idx_lembrete_destinatarios_funcionario ON lembrete_destinatarios(funcionario_id);
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE fila_envio (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(30) NOT NULL,
|
||||
referencia_id BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
|
||||
agendado_para TIMESTAMP NOT NULL,
|
||||
tentativas INTEGER NOT NULL DEFAULT 0,
|
||||
ultimo_erro TEXT,
|
||||
locked_at TIMESTAMP,
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
atualizado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
UNIQUE (tipo, referencia_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fila_envio_pendentes ON fila_envio(status, agendado_para);
|
||||
|
||||
INSERT INTO fila_envio (tipo, referencia_id, status, agendado_para)
|
||||
SELECT 'lembrete', id, 'pendente', COALESCE(data_hora, now())
|
||||
FROM lembretes
|
||||
WHERE status = 'ativo'
|
||||
ON CONFLICT (tipo, referencia_id) DO UPDATE
|
||||
SET status = 'pendente', agendado_para = EXCLUDED.agendado_para, atualizado_em = now();
|
||||
|
||||
INSERT INTO fila_envio (tipo, referencia_id, status, agendado_para)
|
||||
SELECT 'aviso', id, 'pendente', COALESCE(data_evento, now())
|
||||
FROM avisos
|
||||
WHERE (enviar_whatsapp = true OR enviar_email = true)
|
||||
AND status_envio IN ('nao_enviado', 'agendado')
|
||||
ON CONFLICT (tipo, referencia_id) DO UPDATE
|
||||
SET status = 'pendente', agendado_para = EXCLUDED.agendado_para, atualizado_em = now();
|
||||
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS recorrencia_meses INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS recorrencia_ativa BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE lembretes ADD COLUMN IF NOT EXISTS ultimo_envio TIMESTAMP;
|
||||
|
||||
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS recorrencia_meses INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS recorrencia_ativa BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE avisos ADD COLUMN IF NOT EXISTS ultimo_envio TIMESTAMP;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lembretes_recorrencia ON lembretes(recorrencia_ativa, data_hora);
|
||||
CREATE INDEX IF NOT EXISTS idx_avisos_recorrencia ON avisos(recorrencia_ativa, data_evento);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS destinatarios_extra (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
nome VARCHAR(160) NOT NULL,
|
||||
email VARCHAR(160),
|
||||
telefone VARCHAR(30),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
|
||||
criado_em TIMESTAMP NOT NULL DEFAULT now(),
|
||||
atualizado_em TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_destinatarios_extra_status ON destinatarios_extra(status, nome);
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Detalhe do Condominio</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Detalhe do Condominio</h1></div><section class="panel" id="header"></section><div class="nav-tabs"><button data-tab="contatos" class="active">Contatos</button><button data-tab="espacos">Espacos</button><button data-tab="assembleias">Assembleias</button><button data-tab="documentos">Documentos</button><button data-tab="avisos">Avisos</button></div><section class="panel tab" id="contatos"></section><section class="panel tab hidden" id="espacos"></section><section class="panel tab hidden" id="assembleias"></section><section class="panel tab hidden" id="documentos"></section><section class="panel tab hidden" id="avisos"></section></main><script>Layout.mount('empresa','condominios');</script><script src="/cliente/condominio-detalhe.js"></script></body></html>
|
||||
@@ -0,0 +1,30 @@
|
||||
const id=new URLSearchParams(location.search).get('id');const sections=['contatos','espacos','assembleias','documentos','avisos'];document.querySelectorAll('.nav-tabs button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');sections.forEach(s=>document.querySelector('#'+s).classList.toggle('hidden',s!==b.dataset.tab))});let contatos=[],docs=[],avisos=[],funcionariosVinculados=[],extras=[],assembleiasCalendario=[],calMonth=new Date();function recLabel(v){return {1:'Mensal',3:'Trimestral',12:'Anual'}[Number(v)]||'Sem recorrencia'}
|
||||
|
||||
function dateKey(v){const d=new Date(v);if(Number.isNaN(d.getTime()))return'';const pad=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())}
|
||||
function monthLabel(d){return d.toLocaleDateString('pt-BR',{month:'long',year:'numeric'})}
|
||||
function ensureCalendarModal(){let m=document.querySelector('#assembleia-calendar-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="assembleia-calendar-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharCalendarioAssembleias()"></div><section class="modal-panel calendar-modal"><div class="modal-head"><h2>Calendário de assembleias</h2><button class="btn secondary" type="button" onclick="fecharCalendarioAssembleias()">Fechar</button></div><div class="calendar-layout"><div><div class="calendar-toolbar"><button class="btn secondary" type="button" onclick="mudarMesCalendario(-1)">Anterior</button><strong id="calendar-title"></strong><button class="btn secondary" type="button" onclick="mudarMesCalendario(1)">Próximo</button></div><div id="calendar-grid" class="calendar-grid"></div></div><aside class="calendar-side"><h3 id="calendar-day-title">Selecione um dia</h3><div id="calendar-day-list" class="calendar-day-list"><p class="muted">Clique em um dia para ver as assembleias e notificados.</p></div></aside></div></section></div>');return document.querySelector('#assembleia-calendar-modal')}
|
||||
function renderCalendarioAssembleias(selected){ensureCalendarModal();const title=document.querySelector('#calendar-title'),grid=document.querySelector('#calendar-grid');title.textContent=monthLabel(calMonth);const y=calMonth.getFullYear(),m=calMonth.getMonth(),first=new Date(y,m,1),start=new Date(y,m,1-first.getDay()),todayKey=dateKey(new Date());let html=['Dom','Seg','Ter','Qua','Qui','Sex','Sáb'].map(x=>'<div class="calendar-weekday">'+x+'</div>').join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(start.getDate()+i);const key=dateKey(d),items=assembleiasCalendario.filter(a=>dateKey(a.data_hora)===key),other=d.getMonth()!==m,classes=['calendar-day'];if(other)classes.push('other');if(items.length)classes.push('busy');if(key===todayKey)classes.push('today');html+='<button type="button" class="'+classes.join(' ')+'" data-date="'+key+'"><span>'+d.getDate()+'</span>'+(items.length?'<b>'+items.length+'</b>':'')+'</button>'}grid.innerHTML=html;grid.querySelectorAll('.calendar-day').forEach(btn=>btn.onclick=()=>selecionarDiaCalendario(btn.dataset.date));if(selected)selecionarDiaCalendario(selected,false)}
|
||||
window.abrirCalendarioAssembleias=async()=>{try{const d=await Api.get('/api/assembleias-calendario');assembleiasCalendario=d.assembleias||[];calMonth=new Date();ensureCalendarModal().classList.remove('hidden');renderCalendarioAssembleias()}catch(e){Util.toast(e.message,true)}};
|
||||
window.fecharCalendarioAssembleias=()=>document.querySelector('#assembleia-calendar-modal')?.classList.add('hidden');
|
||||
window.mudarMesCalendario=delta=>{calMonth=new Date(calMonth.getFullYear(),calMonth.getMonth()+delta,1);renderCalendarioAssembleias()};
|
||||
window.selecionarDiaCalendario=(key,mark=true)=>{if(mark)document.querySelectorAll('.calendar-day').forEach(x=>x.classList.toggle('selected',x.dataset.date===key));const list=document.querySelector('#calendar-day-list'),title=document.querySelector('#calendar-day-title'),items=assembleiasCalendario.filter(a=>dateKey(a.data_hora)===key);const [yy,mm,dd]=key.split('-');title.textContent=dd+'/'+mm+'/'+yy;if(!items.length){list.innerHTML='<p class="muted">Nenhuma assembleia agendada neste dia.</p>';return;}list.innerHTML=items.map(a=>{const dest=(a.destinatarios||[]).map(x=>x.nome+' ('+x.tipo+')').join(', ')||'Nenhum destinatário registrado';return '<div class="calendar-event"><strong>'+a.condominio_nome+'</strong><span>'+Util.date(a.data_hora)+'</span><span>'+a.tipo+' - '+(a.local||'-')+'</span><p>'+dest+'</p><div class="row compact"><button class="btn secondary" type="button" onclick="reenviarAvisoAssembleia('+a.condominio_id+','+a.id+')">Reenviar notificação</button><button class="btn danger" type="button" onclick="cancelarAssembleiaCalendario('+a.condominio_id+','+a.id+')">Cancelar assembleia</button></div></div>'}).join('')};
|
||||
window.reenviarAvisoAssembleia=async(cond,aid)=>{try{const a=assembleiasCalendario.find(x=>Number(x.id)===Number(aid));if(!a||!a.aviso_id)throw new Error('Aviso da assembleia não encontrado.');await Api.post('/api/condominios/'+cond+'/avisos/'+a.aviso_id+'/reenviar',{});Util.toast('Notificação reenviada')}catch(e){Util.toast(e.message,true)}};
|
||||
window.cancelarAssembleiaCalendario=async(cond,aid)=>{if(!confirm('Cancelar esta assembleia?'))return;try{await Api.put('/api/condominios/'+cond+'/assembleias/'+aid,{status:'cancelada'});Util.toast('Assembleia cancelada');await abrirCalendarioAssembleias();await loadAssembleias();await load()}catch(e){Util.toast(e.message,true)}};
|
||||
function ensureAvisoNotifModal(){let m=document.querySelector('#aviso-notif-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="aviso-notif-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharAvisoNotificacao()"></div><section class="modal-panel"><div class="modal-head"><h2>Notificação</h2><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Fechar</button></div><div id="aviso-notif-body"></div><div class="row"><button class="btn" type="button" onclick="salvarAvisoNotificacao()">Salvar</button><button class="btn secondary" type="button" onclick="fecharAvisoNotificacao()">Cancelar</button></div></section></div>');return document.querySelector('#aviso-notif-modal')}
|
||||
function avisoDestInputs(){return [...document.querySelectorAll('#aviso-dests-hidden input[name=dest]')]}
|
||||
function updateAvisoNotifSummary(){const n=avisoDestInputs().filter(x=>x.checked).length;const el=document.querySelector('#aviso-notif-summary');if(el)el.textContent=n?(n+' selecionado'+(n>1?'s':'')):'Nenhuma pessoa selecionada'}
|
||||
window.abrirAvisoNotificacao=()=>{const m=ensureAvisoNotifModal(),ids=new Set(avisoDestInputs().filter(x=>x.checked).map(x=>x.value));const pessoas=[...contatos.map(c=>({value:'contato:'+c.contato_id,nome:c.contato_nome,tipo:c.tipo})),...funcionariosVinculados.map(f=>({value:'funcionario:'+(f.funcionario_id||f.id),nome:f.nome,tipo:'funcionario'})),...extras.map(x=>({value:'extra:'+x.id,nome:x.nome,tipo:'extra'}))];document.querySelector('#aviso-notif-body').innerHTML=pessoas.map(p=>'<label><input type="checkbox" value="'+p.value+'" '+(ids.has(p.value)?'checked':'')+'> '+p.nome+' ('+p.tipo+')</label>').join('')||'<p class="muted">Nenhuma pessoa cadastrada.</p>';m.classList.remove('hidden')};
|
||||
window.fecharAvisoNotificacao=()=>document.querySelector('#aviso-notif-modal')?.classList.add('hidden');
|
||||
window.salvarAvisoNotificacao=()=>{const ids=new Set([...document.querySelectorAll('#aviso-notif-body input:checked')].map(x=>x.value));avisoDestInputs().forEach(x=>x.checked=ids.has(x.value));updateAvisoNotifSummary();fecharAvisoNotificacao()};
|
||||
async function safeLoad(section,fn){try{await fn()}catch(e){document.querySelector('#'+section).innerHTML=`<p>${e.message}</p>`;Util.toast(e.message,true)}}
|
||||
async function load(){try{const d=await Api.get(`/api/condominios/${id}`);document.querySelector('#header').innerHTML=`<h2>${d.condominio.nome}</h2><p>${d.condominio.endereco||''}</p><div class="cards"><div class="stat">Sindico<strong>${d.sindico_atual?.contato_nome||'-'}</strong></div><div class="stat">Assembleias futuras<strong>${d.assembleias_futuras||0}</strong></div><div class="stat">Avisos ativos<strong>${d.avisos_ativos||0}</strong></div></div>`;await safeLoad('contatos',loadContatos);await safeLoad('espacos',loadEspacos);await safeLoad('assembleias',loadAssembleias);await safeLoad('documentos',loadDocs);await safeLoad('avisos',loadFuncionariosVinculados);await safeLoad('avisos',loadExtras);await safeLoad('avisos',loadAvisos)}catch(e){Util.toast(e.message,true)}}
|
||||
async function loadContatos(){const d=await Api.get(`/api/condominios/${id}/contatos`);contatos=d.contatos||[];document.querySelector('#contatos').innerHTML=`<h2>Contatos</h2><form id="contato-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Telefone</label><input name="telefone"></div><div class="field"><label>E-mail</label><input name="email"></div><div class="field"><label>Tipo</label><select name="tipo"><option value="sindico">Sindico</option><option value="subsindico">Subsindico</option><option value="conselheiro">Conselheiro</option></select></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>Tipo</th><th>Telefone</th><th>Email</th></tr></thead><tbody>${contatos.map(c=>`<tr><td>${c.contato_nome}</td><td>${c.tipo}</td><td>${c.contato_telefone||''}</td><td>${c.contato_email||''}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#contato-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/contatos/vincular`,Util.formData(e.target));Util.toast('Contato salvo');loadContatos()}catch(err){Util.toast(err.message,true)}}}
|
||||
async function loadEspacos(){const d=await Api.get(`/api/condominios/${id}/espacos-comuns`);document.querySelector('#espacos').innerHTML=`<h2>Espacos comuns</h2><form id="espaco-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Valor centavos</label><input name="valor_utilizacao_centavos" type="number"></div><div class="field"><label>Indice</label><input name="indice_correcao"></div><div class="field"><label>Descricao</label><input name="descricao"></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>Valor</th><th>Indice</th><th>Descricao</th></tr></thead><tbody>${(d.espacos||[]).map(x=>`<tr><td>${x.nome}</td><td>${Util.money(x.valor_utilizacao_centavos)}</td><td>${x.indice_correcao||''}</td><td>${x.descricao||''}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#espaco-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/espacos-comuns`,Util.formData(e.target));Util.toast('Espaco salvo');loadEspacos()}catch(err){Util.toast(err.message,true)}}}
|
||||
async function loadAssembleias(){const d=await Api.get(`/api/condominios/${id}/assembleias`);document.querySelector('#assembleias').innerHTML=`<div class="section-head"><h2>Assembleias</h2><button class="icon-btn calendar-open" type="button" onclick="abrirCalendarioAssembleias()" title="Calendário de assembleias" aria-label="Calendário de assembleias"></button></div><form id="ass-form" class="grid cols"><div class="field"><label>Tipo</label><input name="tipo" required></div><div class="field"><label>Data e hora</label><input name="data_hora" type="datetime-local" required></div><div class="field"><label>Local</label><input name="local"></div><div class="field"><label>Pauta</label><input name="pauta"></div><button class="btn">Cadastrar</button></form><div class="table-wrap"><table><thead><tr><th>Tipo</th><th>Data</th><th>Local</th><th>Status</th></tr></thead><tbody>${(d.assembleias||[]).map(a=>`<tr><td>${a.tipo}</td><td>${Util.date(a.data_hora)}</td><td>${a.local||''}</td><td>${Util.badge(a.status)}</td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#ass-form').onsubmit=async e=>{e.preventDefault();try{await Api.post(`/api/condominios/${id}/assembleias`,Util.formData(e.target));Util.toast('Assembleia salva');loadAssembleias()}catch(err){Util.toast(err.message,true)}}}
|
||||
async function loadFuncionariosVinculados(){const d=await Api.get(`/api/condominios/${id}/funcionarios-vinculados`);funcionariosVinculados=d.funcionarios||[]}
|
||||
async function loadExtras(){const d=await Api.get(`/api/extras`);extras=(d.extras||[]).filter(x=>x.status==='ativo')}
|
||||
async function loadDocs(){const d=await Api.get(`/api/condominios/${id}/documentos`);docs=d.documentos||[];document.querySelector('#documentos').innerHTML=`<h2>Documentos</h2><form id="doc-form" class="row"><div class="field"><label>Tipo</label><input name="tipo"></div><div class="field"><label>Arquivo</label><input name="arquivo" type="file" required></div><button class="btn">Enviar</button></form><div class="table-wrap"><table><thead><tr><th>ID</th><th>Arquivo</th><th>Tipo</th><th>Download</th></tr></thead><tbody>${docs.map(d=>`<tr><td>${d.id}</td><td>${d.nome_arquivo}</td><td>${d.tipo||''}</td><td><a href="/api/documentos/${d.id}/download">Baixar</a></td></tr>`).join('')}</tbody></table></div>`;document.querySelector('#doc-form').onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target);try{await Api.post(`/api/condominios/${id}/documentos`,fd);Util.toast('Documento enviado');loadDocs()}catch(err){Util.toast(err.message,true)}}}
|
||||
async function loadAvisos(){const d=await Api.get(`/api/condominios/${id}/avisos`);avisos=d.avisos||[];const destContatos=contatos.map(c=>`<label><input type="checkbox" name="dest" value="contato:${c.contato_id}"> ${c.contato_nome} (${c.tipo})</label>`).join('');const destFuncionarios=funcionariosVinculados.map(f=>`<label><input type="checkbox" name="dest" value="funcionario:${f.funcionario_id||f.id}"> ${f.nome} (funcionario)</label>`).join('');const destExtras=extras.map(x=>`<label><input type="checkbox" name="dest" value="extra:${x.id}"> ${x.nome} (extra)</label>`).join('');const dest=[destContatos,destFuncionarios,destExtras].filter(Boolean).join('');const docChecks=docs.map(d=>`<label><input type="checkbox" name="doc" value="${d.id}"> ${d.nome_arquivo}</label>`).join('');document.querySelector('#avisos').innerHTML=`<h2>Avisos</h2><form id="aviso-form" class="grid cols"><div class="field"><label>Titulo</label><input name="titulo" required></div><div class="field"><label>Tipo</label><input name="tipo" value="aviso"></div><div class="field"><label>Prioridade</label><select name="prioridade"><option>normal</option><option>alta</option><option>urgente</option></select></div><div class="field"><label>Mensagem</label><textarea name="mensagem" required></textarea></div><div class="field"><label>Data/hora de envio</label><input name="data_evento" type="datetime-local"></div><div class="field"><label>Recorrencia</label><select name="recorrencia_meses"><option value="0">Sem recorrencia</option><option value="1">1 vez por mes</option><option value="3">A cada 3 meses</option><option value="12">1 vez por ano</option></select></div><div class="field"><label>Destinatarios</label><button class="btn secondary" type="button" onclick="abrirAvisoNotificacao()">Notificação</button> <span id="aviso-notif-summary" class="muted">Nenhuma pessoa selecionada</span><div id="aviso-dests-hidden" class="hidden">${dest}</div></div><div class="field"><label>Documentos</label>${docChecks||'<p>Nenhum documento.</p>'}</div><label><input type="checkbox" name="enviar_whatsapp" value="true"> Enviar por WhatsApp</label><label><input type="checkbox" name="enviar_email" value="true"> Enviar por e-mail</label><button class="btn">Criar aviso</button></form><div class="table-wrap"><table><thead><tr><th>Titulo</th><th>Status</th><th>Envio</th><th>Publicado em</th><th>Recorrencia</th><th>Acoes</th></tr></thead><tbody>${avisos.map(a=>`<tr><td>${a.titulo}</td><td>${Util.badge(a.status_envio)}</td><td>${Util.date(a.data_evento)}</td><td>${Util.date(a.data_publicacao)}</td><td>${recLabel(a.recorrencia_meses)}</td><td><button class="btn secondary" onclick="reenviarAviso(${a.id})">Reenviar</button> <button class="btn danger" onclick="excluirAviso(${a.id})">Excluir</button></td></tr>`).join('')}</tbody></table></div>`;updateAvisoNotifSummary();document.querySelector('#aviso-form').onsubmit=async e=>{e.preventDefault();const f=e.target,b=Util.formData(f);b.enviar_whatsapp=!!f.enviar_whatsapp.checked;b.enviar_email=!!f.enviar_email.checked;b.destinatarios=[...f.querySelectorAll('input[name=dest]:checked')].map(x=>{const [tipo,did]=x.value.split(':');return{tipo_destinatario:tipo,id:Number(did)}});b.documentos=[...f.querySelectorAll('input[name=doc]:checked')].map(x=>Number(x.value));try{await Api.post(`/api/condominios/${id}/avisos`,b);Util.toast('Aviso criado');loadAvisos();load()}catch(err){Util.toast(err.message,true)}}}
|
||||
window.reenviarAviso=async avisoId=>{try{await Api.post(`/api/condominios/${id}/avisos/${avisoId}/reenviar`,{});Util.toast('Aviso reenviado');loadAvisos()}catch(err){Util.toast(err.message,true)}};
|
||||
window.excluirAviso=async avisoId=>{try{await Api.request(`/api/condominios/${id}/avisos/${avisoId}`,{method:'DELETE'});Util.toast('Aviso excluido');loadAvisos();load()}catch(err){Util.toast(err.message,true)}};
|
||||
load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Cadastro</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Cadastro</h1></div><section class="panel"><h2>Novo condominio</h2><form id="cond-form" class="row"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>CNPJ</label><input name="cnpj" required></div><div class="field"><label>Endereco</label><input name="endereco"></div><button class="btn">Criar</button></form><div class="panel-block"><h2>Condominios</h2><div class="table-wrap"><table><thead><tr><th>ID</th><th>Nome</th><th>CNPJ</th><th>Endereco</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></div></section><section id="funcionarios-panel" class="panel hidden"><h2>Funcionarios</h2><form id="func-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>E-mail de login</label><input name="email" type="email" required></div><div class="field"><label>Telefone/WhatsApp</label><input name="telefone"></div><div class="field"><label>Senha inicial</label><input name="senha" type="password" required></div><button class="btn">Cadastrar funcionario</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>E-mail</th><th>Telefone</th><th>Cargo</th><th>Status</th><th>Condominios</th><th>Opcoes</th></tr></thead><tbody id="func-rows"></tbody></table></div></section><section id="extras-panel" class="panel hidden"><h2>Extra</h2><form id="extra-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>E-mail</label><input name="email" type="email"></div><div class="field"><label>WhatsApp</label><input name="telefone"></div><button class="btn">Cadastrar extra</button></form><div class="table-wrap"><table><thead><tr><th>Nome</th><th>E-mail</th><th>WhatsApp</th><th>Status</th><th>Opcoes</th></tr></thead><tbody id="extra-rows"></tbody></table></div></section><div id="cond-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharModalCondominios()"></div><section class="modal-panel"><div class="modal-head"><h2 id="cond-modal-title">Condominios</h2><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Fechar</button></div><div id="cond-modal-body"></div></section></div></main><script>Layout.mount('empresa','condominios');</script><script src="/cliente/condominios.js"></script></body></html>
|
||||
@@ -0,0 +1,29 @@
|
||||
const form=document.querySelector('#cond-form'),rows=document.querySelector('#rows');
|
||||
let condominios=[],funcionarios=[],extras=[],funcLinks=new Map(),funcAtual=null,funcEditId=null;
|
||||
async function load(){try{const d=await Api.get('/api/condominios');condominios=d.condominios||[];rows.innerHTML=condominios.map(c=>`<tr><td>${c.id}</td><td>${c.nome}</td><td>${c.cnpj||''}</td><td>${c.endereco||''}</td><td><a class="btn secondary" href="/cliente/condominio-detalhe.html?id=${c.id}">Abrir</a></td></tr>`).join('')}catch(e){Util.toast(e.message,true)}}
|
||||
form.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/condominios',Util.formData(form));form.reset();Util.toast('Condominio criado');await load();await loadFuncionarios()}catch(err){Util.toast(err.message,true)}};
|
||||
load();
|
||||
|
||||
const funcPanel=document.querySelector('#funcionarios-panel'),funcForm=document.querySelector('#func-form'),funcRows=document.querySelector('#func-rows'),extraPanel=document.querySelector('#extras-panel'),extraForm=document.querySelector('#extra-form'),extraRows=document.querySelector('#extra-rows'),modal=document.querySelector('#cond-modal'),modalTitle=document.querySelector('#cond-modal-title'),modalBody=document.querySelector('#cond-modal-body');
|
||||
function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||
async function linksPorFuncionario(){const mapa=new Map();await Promise.all(condominios.map(async c=>{try{const d=await Api.get(`/api/condominios/${c.id}/funcionarios-vinculados`);(d.funcionarios||[]).forEach(f=>{const fid=Number(f.funcionario_id||f.id);if(!mapa.has(fid))mapa.set(fid,[]);mapa.get(fid).push({id:Number(c.id),nome:c.nome});});}catch(e){}}));return mapa;}
|
||||
async function refreshLinks(){funcLinks=await linksPorFuncionario()}
|
||||
async function loadFuncionarios(){if(!Auth.isAdminEmpresa())return;funcPanel.classList.remove('hidden');try{if(!condominios.length)await load();const d=await Api.get('/api/funcionarios');funcionarios=d.funcionarios||[];await refreshLinks();funcRows.innerHTML=funcionarios.map(f=>{const acao=f.cargo==='admin_empresa'?'<span class="muted">Acesso total</span>':'<button class="btn secondary" type="button" onclick="abrirModalCondominios('+f.id+')">Condominios</button>';const opcoes=f.cargo==='admin_empresa'?'<span class="muted">-</span>':'<button class="btn secondary" type="button" onclick="abrirEditarFuncionario('+f.id+')">Editar</button> <button class="btn danger" type="button" onclick="abrirExcluirFuncionario('+f.id+')">Excluir</button>';return '<tr><td>'+esc(f.nome)+'</td><td>'+esc(f.email)+'</td><td>'+esc(f.telefone||'')+'</td><td>'+esc(f.cargo||'')+'</td><td>'+Util.badge(f.status)+'</td><td>'+acao+'</td><td>'+opcoes+'</td></tr>'}).join('')||'<tr><td colspan="7">Nenhum funcionario cadastrado.</td></tr>'}catch(e){Util.toast(e.message,true)}}
|
||||
if(funcForm){funcForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/funcionarios',Util.formData(funcForm));funcForm.reset();Util.toast('Funcionario cadastrado');loadFuncionarios()}catch(err){Util.toast(err.message,true)}}}
|
||||
function funcionarioPorId(funcId){return funcionarios.find(x=>Number(x.id)===Number(funcId))}
|
||||
window.abrirEditarFuncionario=funcId=>{const f=funcionarioPorId(funcId);if(!f)return;modalTitle.textContent='Editar funcionario';modalBody.innerHTML='<form id="func-edit-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required value="'+esc(f.nome||'')+'"></div><div class="field"><label>E-mail de login</label><input name="email" type="email" required value="'+esc(f.email||'')+'"></div><div class="field"><label>Telefone/WhatsApp</label><input name="telefone" value="'+esc(f.telefone||'')+'"></div><div class="field"><label>Status</label><select name="status"><option value="ativo" '+(f.status==='ativo'?'selected':'')+'>Ativo</option><option value="inativo" '+(f.status==='inativo'?'selected':'')+'>Inativo</option></select></div><div class="field"><label>Nova senha</label><input name="senha" type="password"></div><div class="row"><button class="btn">Salvar</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div></form>';modal.classList.remove('hidden');document.querySelector('#func-edit-form').onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/funcionarios/'+funcId,Util.formData(e.target));Util.toast('Funcionario atualizado');fecharModalCondominios();loadFuncionarios()}catch(err){Util.toast(err.message,true)}}};
|
||||
window.abrirExcluirFuncionario=funcId=>{const f=funcionarioPorId(funcId);if(!f)return;modalTitle.textContent='Excluir funcionario';modalBody.innerHTML='<p>Deseja realmente excluir o acesso de <strong>'+esc(f.nome||'')+'</strong>?</p><p class="muted">O funcionario sera inativado e perdera acesso ao sistema.</p><div class="row"><button class="btn danger" type="button" onclick="confirmarExcluirFuncionario('+funcId+')">Excluir</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div>';modal.classList.remove('hidden')};
|
||||
window.confirmarExcluirFuncionario=async funcId=>{try{await Api.request('/api/funcionarios/'+funcId,{method:'DELETE'});Util.toast('Funcionario inativado');fecharModalCondominios();loadFuncionarios()}catch(err){Util.toast(err.message,true)}};
|
||||
function renderModalCondominios(){const f=funcionarios.find(x=>Number(x.id)===Number(funcAtual));if(!f)return;const vinculados=funcLinks.get(Number(funcAtual))||[];const vinculadosIds=new Set(vinculados.map(c=>Number(c.id)));const disponiveis=condominios.filter(c=>!vinculadosIds.has(Number(c.id)));modalTitle.textContent=`Condominios de ${f.nome}`;const listaVinculados=vinculados.map(c=>`<div class="cond-item"><span>${esc(c.nome)}</span><button class="btn danger" type="button" onclick="desvincularFuncionarioCondominio(${funcAtual},${c.id})">Desvincular</button></div>`).join('')||'<p class="muted">Nenhum condominio vinculado.</p>';const listaDisponiveis=disponiveis.map(c=>`<div class="cond-item"><span>${esc(c.nome)}</span><button class="btn secondary" type="button" onclick="vincularFuncionarioCondominio(${funcAtual},${c.id})">Vincular</button></div>`).join('')||'<p class="muted">Todos os condominios ja estao vinculados.</p>';modalBody.innerHTML=`<div class="modal-grid"><section><h3>Vinculados</h3>${listaVinculados}</section><section><h3>Nao vinculados</h3>${listaDisponiveis}</section></div>`}
|
||||
window.abrirModalCondominios=async funcId=>{funcAtual=Number(funcId);try{await refreshLinks();renderModalCondominios();modal.classList.remove('hidden')}catch(err){Util.toast(err.message,true)}};
|
||||
window.fecharModalCondominios=()=>{modal.classList.add('hidden');funcAtual=null};
|
||||
window.vincularFuncionarioCondominio=async (funcId,condId)=>{try{await Api.post(`/api/condominios/${condId}/funcionarios-vinculados`,{funcionario_id:funcId});Util.toast('Funcionario vinculado');await refreshLinks();renderModalCondominios()}catch(err){Util.toast(err.message,true)}};
|
||||
window.desvincularFuncionarioCondominio=async (funcId,condId)=>{try{await Api.request(`/api/condominios/${condId}/funcionarios-vinculados/${funcId}`,{method:'DELETE'});Util.toast('Funcionario desvinculado');await refreshLinks();renderModalCondominios()}catch(err){Util.toast(err.message,true)}};
|
||||
loadFuncionarios();
|
||||
|
||||
async function loadExtras(){if(!Auth.isAdminEmpresa())return;extraPanel.classList.remove('hidden');try{const d=await Api.get('/api/extras');extras=d.extras||[];extraRows.innerHTML=extras.map(x=>'<tr><td>'+esc(x.nome)+'</td><td>'+esc(x.email||'')+'</td><td>'+esc(x.telefone||'')+'</td><td>'+Util.badge(x.status)+'</td><td><button class="btn secondary" type="button" onclick="abrirEditarExtra('+x.id+')">Editar</button> <button class="btn danger" type="button" onclick="excluirExtra('+x.id+')">Excluir</button></td></tr>').join('')||'<tr><td colspan="5">Nenhum destinatario extra cadastrado.</td></tr>'}catch(e){Util.toast(e.message,true)}}
|
||||
if(extraForm){extraForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/extras',Util.formData(extraForm));extraForm.reset();Util.toast('Extra cadastrado');loadExtras()}catch(err){Util.toast(err.message,true)}}}
|
||||
function extraPorId(id){return extras.find(x=>Number(x.id)===Number(id))}
|
||||
window.abrirEditarExtra=id=>{const x=extraPorId(id);if(!x)return;modalTitle.textContent='Editar extra';modalBody.innerHTML='<form id="extra-edit-form" class="grid cols"><div class="field"><label>Nome</label><input name="nome" required value="'+esc(x.nome||'')+'"></div><div class="field"><label>E-mail</label><input name="email" type="email" value="'+esc(x.email||'')+'"></div><div class="field"><label>WhatsApp</label><input name="telefone" value="'+esc(x.telefone||'')+'"></div><div class="field"><label>Status</label><select name="status"><option value="ativo" '+(x.status==='ativo'?'selected':'')+'>Ativo</option><option value="inativo" '+(x.status==='inativo'?'selected':'')+'>Inativo</option></select></div><div class="row"><button class="btn">Salvar</button><button class="btn secondary" type="button" onclick="fecharModalCondominios()">Cancelar</button></div></form>';modal.classList.remove('hidden');document.querySelector('#extra-edit-form').onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/extras/'+id,Util.formData(e.target));Util.toast('Extra atualizado');fecharModalCondominios();loadExtras()}catch(err){Util.toast(err.message,true)}}};
|
||||
window.excluirExtra=async id=>{try{await Api.request('/api/extras/'+id,{method:'DELETE'});Util.toast('Extra excluido');loadExtras()}catch(err){Util.toast(err.message,true)}};
|
||||
loadExtras();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WhatsApp</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>WhatsApp</h1></div><section class="panel"><h2>WhatsApp</h2><form id="wa-form" class="row"><div class="field"><label>Numero WhatsApp</label><input name="numero_whatsapp" required placeholder="5511999999999"></div><button class="btn">Cadastrar</button></form></section><section class="panel"><h2>Status</h2><div id="status"></div><div class="row"><button id="btn-qr" class="btn secondary" type="button">Conectar via QR Code</button><button id="btn-reconnect" class="btn secondary" type="button">Reconectar</button><button id="btn-disconnect" class="btn danger" type="button">Desconectar</button></div><div id="qr-box" class="hidden"><h2>QR Code</h2><img id="qr" class="qr hidden" alt="QR Code Evolution"><div class="field"><label>Codigo de pareamento</label><input id="pairing" readonly></div></div></section></main><script>Layout.mount('empresa','configuracoes');</script><script src="/cliente/configuracoes.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
const form=document.querySelector('#wa-form'),statusEl=document.querySelector('#status'),qr=document.querySelector('#qr'),qrBox=document.querySelector('#qr-box'),pairing=document.querySelector('#pairing'),btnQr=document.querySelector('#btn-qr'),btnReconnect=document.querySelector('#btn-reconnect'),btnDisconnect=document.querySelector('#btn-disconnect');async function load(){try{const d=await Api.get('/api/config/evolution-instancia/status');const i=d.instancia;const evo=d.evolution||{};statusEl.innerHTML=i?`<p>Status aprovacao: ${Util.badge(i.status)}</p><p>Numero: ${i.numero_whatsapp||'-'}</p><p>Conexao: ${Util.badge(evo.state||'-')}</p>`:`<p>Status: ${Util.badge(d.status)}</p>`;const has=i!=null;btnQr.disabled=!has;btnReconnect.disabled=!has;btnDisconnect.disabled=!has}catch(e){Util.toast(e.message,true)}}function showQr(d){qrBox.classList.remove('hidden');const base64=d.qrcode_base64||d.qr_code_base64;if(base64){qr.src=base64.startsWith('data:image/')?base64:'data:image/png;base64,'+base64;qr.classList.remove('hidden')}else{qr.classList.add('hidden');Util.toast('Evolution nao retornou QR Code nesta tentativa',true)}pairing.value=d.pairing_code||''}form.onsubmit=async e=>{e.preventDefault();try{const d=await Api.post('/api/config/evolution-instancia/conectar',Util.formData(form));statusEl.innerHTML=`<p class="success-message">WhatsApp cadastrado com sucesso. Aguarde a liberacao no painel Master.</p><p>Status aprovacao: ${Util.badge(d.status)}</p><p>Instancia: ${d.instance_name}</p>`;showQr(d);Util.toast('WhatsApp cadastrado com sucesso');setTimeout(load,1600)}catch(err){Util.toast(err.message,true)}};btnQr.onclick=async()=>{try{const d=await Api.post('/api/config/evolution-instancia/qrcode',{});showQr(d);Util.toast('QR Code atualizado')}catch(e){Util.toast(e.message,true)}};btnReconnect.onclick=async()=>{try{await Api.post('/api/config/evolution-instancia/reconectar',{});Util.toast('Reconexao solicitada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};btnDisconnect.onclick=async()=>{if(!confirm('Desconectar esta sessao do WhatsApp?'))return;try{await Api.post('/api/config/evolution-instancia/desconectar',{});Util.toast('Sessao desconectada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Financeiro</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Financeiro</h1></div><section class="panel"><h2>Periodo atual</h2><div id="status" class="cards"></div><button id="gerar" class="btn">Gerar cobranca PIX</button></section><section id="pix" class="panel hidden"><h2>PIX</h2><img id="qr" class="qr" alt="QR Code PIX"><div class="field"><label>Copia e cola</label><textarea id="payload" readonly></textarea></div><button id="copy" class="btn secondary">Copiar</button></section></main><script>Layout.mount('empresa','financeiro');</script><script src="/cliente/financeiro.js"></script></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
let pagamento=null,timer=null;const statusEl=document.querySelector('#status'),pix=document.querySelector('#pix'),qr=document.querySelector('#qr'),payload=document.querySelector('#payload');
|
||||
function data(v){if(!v)return'-';const d=new Date(v+'T00:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('pt-BR')}
|
||||
async function load(){try{const d=await Api.get('/api/financeiro/periodo-atual');pagamento=d.pagamento;const p=d.periodo||{},a=d.assinatura||{};statusEl.innerHTML='<div class="stat">Periodo de utilizacao<strong>'+data(p.data_inicio)+' a '+data(p.data_fim)+'</strong><span class="muted">Mes fechado</span></div><div class="stat">Vencimento<strong>'+data(p.data_vencimento)+'</strong><span class="muted">Dia '+(a.dia_vencimento||7)+' de cada mes</span></div><div class="stat">Mensalidade<strong>'+Util.money(a.valor_mensal_centavos)+'</strong><span>'+Util.badge(p.status)+'</span></div><div class="stat">Pagamento<strong>'+Util.badge(pagamento?.status)+'</strong></div>';if(pagamento&&pagamento.qr_code_base64)showPix(pagamento)}catch(e){Util.toast(e.message,true)}}
|
||||
function showPix(p){pix.classList.remove('hidden');qr.src='data:image/png;base64,'+p.qr_code_base64;payload.value=p.qr_code_payload||'';if(timer)clearInterval(timer);timer=setInterval(async()=>{const d=await Api.get('/api/financeiro/pagamentos/'+p.id);if(d.pagamento?.status==='pago'){clearInterval(timer);Util.toast('Pagamento confirmado');load()}},5000)}
|
||||
document.querySelector('#gerar').onclick=async()=>{try{const d=await Api.post('/api/financeiro/pagamentos',{});pagamento=d.pagamento;showPix(pagamento);load()}catch(e){Util.toast(e.message,true)}};
|
||||
document.querySelector('#copy').onclick=()=>navigator.clipboard.writeText(payload.value).then(()=>Util.toast('Codigo copiado'));
|
||||
load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Lembrete</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Lembrete</h1></div><section class="panel"><h2>Novo lembrete</h2><form id="lembrete-form" class="grid cols"><input type="hidden" name="id"><div class="field"><label>Titulo</label><input name="titulo" required></div><div class="field"><label>Data/hora</label><input name="data_hora" type="datetime-local"></div><div class="field"><label>Status</label><select name="status"><option value="ativo">Ativo</option><option value="concluido">Concluido</option><option value="cancelado">Cancelado</option></select></div><div class="field"><label>Recorrencia</label><select name="recorrencia_meses"><option value="0">Sem recorrencia</option><option value="1">1 vez por mes</option><option value="3">A cada 3 meses</option><option value="12">1 vez por ano</option></select></div><div class="field"><label>Mensagem</label><textarea name="mensagem" required></textarea></div><div class="field"><label>Pessoas que receberao</label><div id="pessoas"></div></div><div class="row"><button class="btn" id="submit-btn">Cadastrar lembrete</button><button class="btn secondary hidden" id="cancel-edit" type="button">Cancelar edicao</button></div></form></section><section class="panel"><h2>Lembretes</h2><div class="table-wrap"><table><thead><tr><th>Titulo</th><th>Data/hora</th><th>Status</th><th>Destinatarios</th><th>Acoes</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>Layout.mount('empresa','lembretes');</script><script src="/cliente/lembretes.js"></script></body></html>
|
||||
@@ -0,0 +1,20 @@
|
||||
const form=document.querySelector('#lembrete-form'),rows=document.querySelector('#rows'),pessoasEl=document.querySelector('#pessoas'),submitBtn=document.querySelector('#submit-btn'),cancelBtn=document.querySelector('#cancel-edit');
|
||||
let pessoas=[],lembretes=[];
|
||||
function esc(v){return String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||
function toInputDate(v){if(!v)return'';const d=new Date(v);if(Number.isNaN(d.getTime()))return'';const pad=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`}
|
||||
function recLabel(v){return {1:"Mensal",3:"Trimestral",12:"Anual"}[Number(v)]||"Sem recorrencia"}
|
||||
function selectedDestIds(){return new Set([...form.querySelectorAll('input[name=dest]:checked')].map(x=>String(x.value)))}
|
||||
function updateNotifSummary(){const n=selectedDestIds().size;const el=document.querySelector('#notif-summary');if(el)el.textContent=n?(n+' selecionado'+(n>1?'s':'')):'Nenhuma pessoa selecionada'}
|
||||
function ensureNotifModal(){let m=document.querySelector('#notif-modal');if(m)return m;document.body.insertAdjacentHTML('beforeend','<div id="notif-modal" class="modal hidden"><div class="modal-backdrop" onclick="fecharNotificacao()"></div><section class="modal-panel"><div class="modal-head"><h2>Notificação</h2><button class="btn secondary" type="button" onclick="fecharNotificacao()">Fechar</button></div><div id="notif-modal-body"></div><div class="row"><button class="btn" type="button" onclick="salvarNotificacao()">Salvar</button><button class="btn secondary" type="button" onclick="fecharNotificacao()">Cancelar</button></div></section></div>');return document.querySelector('#notif-modal')}
|
||||
window.abrirNotificacao=()=>{const m=ensureNotifModal(),ids=selectedDestIds();document.querySelector('#notif-modal-body').innerHTML=pessoas.map(p=>'<label><input type="checkbox" value="'+p.id+'" '+(ids.has(String(p.id))?'checked':'')+'> '+esc(p.nome)+' ('+esc(p.cargo||'')+')</label>').join('')||'<p class="muted">Nenhum funcionario ativo.</p>';m.classList.remove('hidden')};
|
||||
window.fecharNotificacao=()=>document.querySelector('#notif-modal')?.classList.add('hidden');
|
||||
window.salvarNotificacao=()=>{const ids=new Set([...document.querySelectorAll('#notif-modal-body input:checked')].map(x=>String(x.value)));form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=ids.has(String(x.value)));updateNotifSummary();fecharNotificacao()};
|
||||
async function loadPessoas(){const d=await Api.get('/api/lembrete-pessoas');pessoas=d.pessoas||[];pessoasEl.innerHTML='<button class="btn secondary" type="button" onclick="abrirNotificacao()">Notificação</button> <span id="notif-summary" class="muted">Nenhuma pessoa selecionada</span><div id="pessoas-hidden" class="hidden">'+pessoas.map(p=>'<label><input type="checkbox" name="dest" value="'+p.id+'"> '+esc(p.nome)+' ('+esc(p.cargo||'')+')</label>').join('')+'</div>';updateNotifSummary()}
|
||||
async function load(){try{const d=await Api.get('/api/lembretes');lembretes=d.lembretes||[];rows.innerHTML=lembretes.map(l=>{const dest=(l.destinatarios||[]).map(x=>x.nome).join(', ')||'-';const acoes=l.pode_alterar?`<button class="btn" onclick="enviar(${l.id})">Enviar</button> ${l.pode_editar?`<button class="btn secondary" onclick="editar(${l.id})">Editar</button>`:''} <button class="btn danger" onclick="excluir(${l.id})">Excluir</button>`:'-';return `<tr><td><strong>${esc(l.titulo)}</strong><br><span class="muted">${esc(l.mensagem)}</span></td><td>${Util.date(l.data_hora)}<br><span class="muted">${recLabel(l.recorrencia_meses)}</span></td><td>${Util.badge(l.status)}</td><td>${esc(dest)}</td><td>${acoes}</td></tr>`}).join('')||'<tr><td colspan="5">Nenhum lembrete cadastrado.</td></tr>'}catch(e){Util.toast(e.message,true)}}
|
||||
function resetForm(){form.reset();form.id.value='';form.recorrencia_meses.value='0';submitBtn.textContent='Cadastrar lembrete';cancelBtn.classList.add('hidden');form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=false);updateNotifSummary()}
|
||||
window.editar=id=>{const l=lembretes.find(x=>Number(x.id)===Number(id));if(!l)return;if(!l.pode_editar){Util.toast('Este lembrete ja foi enviado e nao pode mais ser editado.',true);return;}form.id.value=l.id;form.titulo.value=l.titulo||'';form.mensagem.value=l.mensagem||'';form.data_hora.value=toInputDate(l.data_hora);form.status.value='ativo';form.recorrencia_meses.value=String(l.recorrencia_meses||0);const ids=new Set((l.destinatarios||[]).map(x=>Number(x.id)));form.querySelectorAll('input[name=dest]').forEach(x=>x.checked=ids.has(Number(x.value)));updateNotifSummary();submitBtn.textContent='Salvar alteracao';cancelBtn.classList.remove('hidden');scrollTo({top:0,behavior:'smooth'})};
|
||||
window.enviar=async id=>{try{const r=await Api.post(`/api/lembretes/${id}/enviar`,{});const en=r.envio||{};Util.toast(en.falhas?("Enviados: "+(en.enviados||0)+". Falhas: "+en.falhas):("Lembrete enviado para "+(en.enviados||0)));await load()}catch(e){Util.toast(e.message,true)}};
|
||||
window.excluir=async id=>{try{await Api.request(`/api/lembretes/${id}`,{method:'DELETE'});Util.toast('Lembrete excluido');await load();resetForm()}catch(e){Util.toast(e.message,true)}};
|
||||
cancelBtn.onclick=resetForm;
|
||||
form.onsubmit=async e=>{e.preventDefault();const b=Util.formData(form);b.destinatarios=[...form.querySelectorAll('input[name=dest]:checked')].map(x=>Number(x.value));try{if(form.id.value){await Api.put(`/api/lembretes/${form.id.value}`,b);Util.toast('Lembrete atualizado')}else{const r=await Api.post('/api/lembretes',b);const en=r.envio||{};Util.toast(en.enfileirado?'Lembrete enfileirado para envio':(en.falhas?('Lembrete salvo. Enviados: '+(en.enviados||0)+'. Falhas: '+en.falhas):'Lembrete cadastrado'))}resetForm();await load()}catch(err){Util.toast(err.message,true)}};
|
||||
(async()=>{try{await loadPessoas();await load();setInterval(load,5000)}catch(e){Util.toast(e.message,true)}})();
|
||||
@@ -0,0 +1,4 @@
|
||||
:root{--bg:#f6f7f9;--panel:#fff;--line:#d9dee7;--text:#1c2430;--muted:#667085;--brand:#176b87;--brand-strong:#0f5268;--ok:#1f8a5b;--warn:#b7791f;--danger:#b42318}*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)}a{color:inherit}button,input,select,textarea{font:inherit}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px}.login-box,.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 16px 40px rgba(16,24,40,.08)}.login-box{width:min(440px,100%);padding:28px}.tabs,.nav-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);margin-bottom:20px}.tabs button,.nav-tabs button{border:0;background:transparent;padding:10px 12px;cursor:pointer;color:var(--muted)}.tabs button.active,.nav-tabs button.active{color:var(--brand);border-bottom:3px solid var(--brand)}.field{display:grid;gap:6px;margin-bottom:12px}label{color:var(--muted);font-size:13px}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:6px;padding:10px 12px;background:#fff}textarea{min-height:88px;resize:vertical}.btn{border:0;border-radius:6px;padding:10px 14px;color:#fff;background:var(--brand);cursor:pointer;display:inline-flex;align-items:center;gap:8px;justify-content:center}.btn:hover{background:var(--brand-strong)}.btn.secondary{color:var(--text);background:#eef2f6}.btn.danger{background:var(--danger)}.app{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:#102a38;color:#fff;padding:22px 16px;display:flex;flex-direction:column;gap:20px}.brand{font-size:20px;font-weight:700}.user{color:#c9d6df;font-size:13px}.menu{display:grid;gap:6px}.menu a{text-decoration:none;padding:10px 12px;border-radius:6px;color:#dbe6ec}.menu a.active,.menu a:hover{background:rgba(255,255,255,.12);color:#fff}.sidebar .btn{margin-top:auto;width:100%;background:rgba(255,255,255,.14)}.content{padding:28px;min-width:0}.topbar{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}h1{margin:0;font-size:26px}h2{margin:0 0 14px;font-size:18px}.grid{display:grid;gap:16px}.cols{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.panel{padding:18px;box-shadow:none}.panel-block{margin-top:16px}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:680px}th,td{padding:10px 8px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}th{color:var(--muted);font-size:12px;text-transform:uppercase}.badge{display:inline-block;padding:4px 8px;border-radius:999px;background:#eef2f6;font-size:12px}.badge.ok{background:#dcfce7;color:var(--ok)}.badge.warn{background:#fef3c7;color:var(--warn)}.toast,.finance-banner{position:fixed;left:50%;transform:translateX(-50%);z-index:20;max-width:min(720px,calc(100vw - 24px));border-radius:8px;padding:12px 14px}.toast{bottom:18px;background:#17202a;color:#fff}.toast.error{background:var(--danger)}.finance-banner{top:12px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412}.hidden{display:none!important}.qr{max-width:260px;border:1px solid var(--line);border-radius:8px;padding:8px;background:#fff}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.stat{border:1px solid var(--line);border-radius:8px;padding:14px;background:#fff}.stat strong{display:block;font-size:22px;margin-top:6px}@media(max-width:820px){.app{grid-template-columns:1fr}.sidebar{position:static}.content{padding:18px}}.status-action{border:0;background:transparent;padding:0;display:inline-flex;align-items:center;gap:8px;cursor:pointer}.status-action span{font-size:12px;color:var(--brand);text-decoration:underline}.status-action:hover span{color:var(--brand-strong)}.inline-panel{background:#f8fafc;border:1px solid var(--line);border-radius:8px;padding:14px}.inline-panel h2{margin-bottom:10px}.inline-panel p{margin:6px 0}.row.compact{align-items:center;gap:8px}.row.compact select{min-width:180px}.link-button{border:0;background:transparent;color:var(--brand);cursor:pointer;font-size:12px;text-decoration:underline;padding:0 0 0 6px}.link-button:hover{color:var(--brand-strong)}
|
||||
.modal{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:24px}.modal-backdrop{position:absolute;inset:0;background:rgba(15,23,42,.45)}.modal-panel{position:relative;width:min(860px,100%);max-height:calc(100vh - 48px);overflow:auto;background:#fff;border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 24px 80px rgba(15,23,42,.24)}.modal-head{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:16px}.modal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.modal-grid h3{margin:0 0 10px;font-size:15px}.cond-item{display:flex;justify-content:space-between;gap:12px;align-items:center;border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:8px}.muted{color:var(--muted)}@media(max-width:720px){.modal-grid{grid-template-columns:1fr}.modal{padding:12px}}
|
||||
.success-message{display:inline-block;margin:0 0 10px;padding:10px 12px;border:1px solid #bbf7d0;border-radius:8px;background:#dcfce7;color:var(--ok);font-weight:700}
|
||||
.section-head{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:14px}.section-head h2{margin:0}.icon-btn{position:relative;width:42px;height:42px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer;display:inline-grid;place-items:center}.icon-btn:hover{border-color:var(--brand);background:#eef8fb}.calendar-open::before{content:"";width:20px;height:18px;border:2px solid var(--brand);border-radius:3px;box-shadow:inset 0 5px 0 #e8f3f7;position:relative}.calendar-open::after{content:"";position:absolute;width:12px;height:2px;background:var(--brand);transform:translateY(-7px)}.calendar-modal{width:min(1120px,100%)}.calendar-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:18px}.calendar-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.calendar-toolbar strong{text-transform:capitalize}.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff}.calendar-weekday{padding:10px;background:#f8fafc;color:var(--muted);font-size:12px;font-weight:700;text-align:center;border-bottom:1px solid var(--line)}.calendar-day{min-height:86px;border:0;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff;padding:8px;text-align:left;color:var(--text);cursor:pointer;position:relative}.calendar-day:nth-child(7n+7){border-right:0}.calendar-day.other{background:#f8fafc;color:#98a2b3}.calendar-day.busy{background:#fff7ed}.calendar-day.busy span{font-weight:700;color:var(--warn)}.calendar-day.today{box-shadow:inset 0 0 0 2px var(--brand)}.calendar-day.selected{background:#e8f3f7}.calendar-day b{position:absolute;right:8px;bottom:8px;background:var(--brand);color:#fff;border-radius:999px;padding:2px 7px;font-size:12px}.calendar-side{border:1px solid var(--line);border-radius:8px;padding:14px;background:#fff;min-height:420px}.calendar-side h3{margin:0 0 12px}.calendar-event{border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;display:grid;gap:6px}.calendar-event span{font-size:13px}.calendar-event p{margin:0;color:var(--muted);font-size:13px}@media(max-width:900px){.calendar-layout{grid-template-columns:1fr}.calendar-day{min-height:64px}.calendar-side{min-height:auto}}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SeConectai</title><script src="/js/auth.js"></script></head><body><script>const session=Auth.session();if(!session) location.href='/login.html';else location.href=session.scope==='master'?'/master/clientes.html':'/cliente/condominios.html';</script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
window.Api={async request(path,options={}){const headers=options.headers||{};const token=Auth.token();if(token)headers.Authorization=`Bearer ${token}`;if(options.body&&!(options.body instanceof FormData))headers['Content-Type']='application/json';const res=await fetch(path,{...options,headers});const text=await res.text();const data=text?JSON.parse(text):{};if(res.status===401){Auth.logout();return}if(res.status===403&&data.erro==='pendencia_financeira'){this.financeBanner(data.mensagem);throw new Error(data.mensagem||'Pendencia financeira')}if(!res.ok)throw new Error(data.mensagem||data.erro||`Erro HTTP ${res.status}`);return data},get(path){return this.request(path)},post(path,body){return this.request(path,{method:'POST',body:body instanceof FormData?body:JSON.stringify(body||{})})},put(path,body){return this.request(path,{method:'PUT',body:JSON.stringify(body||{})})},financeBanner(message){if(document.querySelector('.finance-banner'))return;const banner=document.createElement('div');banner.className='finance-banner';banner.innerHTML=`${message||'Seu acesso esta bloqueado por pendencia de pagamento.'} <a href="/cliente/financeiro.html">Regularizar em Financeiro</a>`;document.body.appendChild(banner)}};
|
||||
@@ -0,0 +1 @@
|
||||
window.Auth={claims(){const t=this.token();if(!t)return{};try{return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')))}catch(_){return{}}},isAdminEmpresa(){return this.claims().cargo==='admin_empresa'},session(){const scope=localStorage.getItem('auth_scope');const token=scope==='master'?localStorage.getItem('master_token'):localStorage.getItem('funcionario_token');return token?{scope,token}:null},token(){const s=this.session();return s?s.token:null},save(scope,token,label){localStorage.setItem('auth_scope',scope);localStorage.setItem(scope==='master'?'master_token':'funcionario_token',token);localStorage.setItem('auth_label',label||(scope==='master'?'Master':'Escritorio'))},logout(){localStorage.removeItem('auth_scope');localStorage.removeItem('master_token');localStorage.removeItem('funcionario_token');localStorage.removeItem('auth_label');location.href='/login.html'},require(scope){const s=this.session();if(!s||(scope&&s.scope!==scope))location.href='/login.html';return s}};
|
||||
@@ -0,0 +1 @@
|
||||
window.Layout={mount(scope,active){Auth.require(scope);const label=localStorage.getItem('auth_label')||(scope==='master'?'Master':'Escritorio');const master=[['Clientes','/master/clientes.html','clientes'],['Planos','/master/planos.html','planos'],['Financeiro','/master/financeiro.html','financeiro'],['Configuracoes','/master/configuracoes.html','configuracoes']];let cliente=[['Cadastro','/cliente/condominios.html','condominios'],['Lembrete','/cliente/lembretes.html','lembretes'],['Financeiro','/cliente/financeiro.html','financeiro'],['WhatsApp','/cliente/configuracoes.html','configuracoes']];if(scope==='empresa'&&!Auth.isAdminEmpresa())cliente=cliente.filter(x=>x[2]!=='configuracoes');const menu=(scope==='master'?master:cliente).map(([n,h,k])=>`<a class="${k===active?'active':''}" href="${h}">${n}</a>`).join('');document.body.insertAdjacentHTML('afterbegin',`<aside class="sidebar"><div><div class="brand">SeConectai</div><div class="user">${label}</div></div><nav class="menu">${menu}</nav><button class="btn" onclick="Auth.logout()">Sair</button></aside>`);document.body.classList.add('app');document.querySelector('main')?.classList.add('content')}};
|
||||
@@ -0,0 +1 @@
|
||||
window.Util={money(cents){return(Number(cents||0)/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'})},date(value){return value?new Date(value).toLocaleString('pt-BR'):'-'},toast(message,error){const el=document.createElement('div');el.className='toast'+(error?' error':'');el.textContent=message;document.body.appendChild(el);setTimeout(()=>el.remove(),3600)},formData(form){return Object.fromEntries(new FormData(form).entries())},badge(status){const s=status||'-';const klass=['ativa','ativo','pago','enviado','aprovada'].includes(s)?'ok':(['pendente','bloqueada','falhou'].includes(s)?'warn':'');return `<span class="badge ${klass}">${s}</span>`}};
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login - SeConectai</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script></head><body class="login-page"><main class="login-box"><h1>SeConectai</h1><p class="user">Acesse o painel administrativo ou o escritorio.</p><div class="tabs"><button id="tab-master" class="active" type="button">Master</button><button id="tab-cliente" type="button">Escritorio</button></div><form id="master-form"><div class="field"><label>Senha master</label><input name="senha" type="password" required></div><button class="btn" type="submit">Entrar</button></form><form id="cliente-form" class="hidden"><div class="field"><label>E-mail</label><input name="email" type="email" required></div><div class="field"><label>Senha</label><input name="senha" type="password" required></div><button class="btn" type="submit">Entrar</button></form></main><script>const masterTab=document.querySelector('#tab-master'),clienteTab=document.querySelector('#tab-cliente'),masterForm=document.querySelector('#master-form'),clienteForm=document.querySelector('#cliente-form');function tab(scope){masterTab.classList.toggle('active',scope==='master');clienteTab.classList.toggle('active',scope==='cliente');masterForm.classList.toggle('hidden',scope!=='master');clienteForm.classList.toggle('hidden',scope!=='cliente')}masterTab.onclick=()=>tab('master');clienteTab.onclick=()=>tab('cliente');async function login(url,body,scope,label){try{const res=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const data=await res.json();if(!res.ok)throw new Error(data.mensagem||data.erro||'Falha no login');Auth.save(scope,data.token,label);location.href=scope==='master'?'/master/clientes.html':'/cliente/condominios.html'}catch(e){Util.toast(e.message,true)}}masterForm.onsubmit=e=>{e.preventDefault();login('/api/master/login',Util.formData(masterForm),'master','Master')};clienteForm.onsubmit=e=>{e.preventDefault();const b=Util.formData(clienteForm);login('/api/auth/login',b,'empresa',b.email)};</script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Clientes</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Clientes</h1></div><section class="panel"><form id="empresa-form" class="grid cols"><div class="field"><label>Slug</label><input name="slug" required></div><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>CNPJ</label><input name="cnpj"></div><div class="field"><label>Plano</label><select name="plano_id" id="plano-select"><option value="">Sem plano</option></select></div><div class="field"><label>Telefone/WhatsApp</label><input name="telefone" placeholder="5599999999999"></div><div class="field"><label>E-mail de login</label><input name="email" type="email" required></div><div class="field"><label>Senha inicial</label><input name="senha" type="password" required></div><button class="btn">Cadastrar</button><div class="field"><label>Busca</label><input id="busca" placeholder="Buscar por slug, nome, CNPJ, status ou plano"></div></form></section><section class="panel"><div class="table-wrap"><table><thead><tr><th>Slug</th><th>Nome</th><th>Status</th><th>Plano</th><th>Editar</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>Layout.mount('master','clientes');</script><script src="/master/clientes.js"></script></body></html>
|
||||
@@ -0,0 +1,18 @@
|
||||
const empresaForm=document.querySelector('#empresa-form'),rows=document.querySelector('#rows'),busca=document.querySelector('#busca'),planoSelect=document.querySelector('#plano-select');let empresas=[],planos=[],modo=null,selecionado=null;
|
||||
function planoLabel(p){return `${p.nome} - ${Util.money(p.valor_mensal_centavos)}`}
|
||||
function planoOptions(valor){return '<option value="">Sem plano</option>'+planos.map(p=>`<option value="${p.id}" ${String(valor||'')===String(p.id)?'selected':''}>${planoLabel(p)}</option>`).join('')}
|
||||
function renderPlanos(){planoSelect.innerHTML=planoOptions('')}
|
||||
function match(e,q){if(!q)return true;return [e.slug,e.nome,e.cnpj,e.status,e.plano].join(' ').toLowerCase().includes(q.toLowerCase())}
|
||||
function statusAction(e){const acao=e.status==='ativa'?'Bloquear':'Ativar';return `<button class="status-action" onclick="alterarStatus(${e.id},'${e.status==='ativa'?'bloqueada':'ativa'}')">${Util.badge(e.status)}<span>${acao}</span></button>`}
|
||||
function detalheRow(e){if(selecionado!==e.id||!modo)return'';if(modo==='ver')return `<tr><td colspan="5"><div class="inline-panel"><h2>${e.nome}</h2><p><strong>Slug:</strong> ${e.slug}</p><p><strong>CNPJ:</strong> ${e.cnpj||'-'}</p><p><strong>Status:</strong> ${e.status}</p><p><strong>Plano:</strong> ${e.plano?`${e.plano} ${e.plano_valor_centavos?'- '+Util.money(e.plano_valor_centavos):''}`:'-'}</p><p><strong>Conta principal:</strong> abra Editar para alterar dados da empresa; funcionarios ficam no acesso da empresa.</p><button class="btn secondary" onclick="fecharDetalhe()">Fechar</button></div></td></tr>`;return `<tr><td colspan="5"><form class="inline-panel grid cols" onsubmit="salvarEdicao(event,${e.id})"><div class="field"><label>Nome</label><input name="nome" value="${e.nome||''}" required></div><div class="field"><label>CNPJ</label><input name="cnpj" value="${e.cnpj||''}"></div><div class="field"><label>Plano</label><select name="plano_id">${planoOptions(e.plano_id)}</select></div><div class="row"><button class="btn">Salvar</button><button type="button" class="btn secondary" onclick="fecharDetalhe()">Cancelar</button></div></form></td></tr>`}
|
||||
function render(){const q=busca.value.trim();const lista=empresas.filter(e=>match(e,q));rows.innerHTML=lista.length?lista.map(e=>`<tr><td>${e.slug}</td><td>${e.nome}</td><td>${statusAction(e)}</td><td>${e.plano?`${e.plano} ${e.plano_valor_centavos?'- '+Util.money(e.plano_valor_centavos):''}`:'-'}</td><td><button class="btn secondary" onclick="editar(${e.id})">Editar</button> <button class="btn secondary" onclick="ver(${e.id})">Ver</button> <button class="btn danger" onclick="excluir(${e.id})">Excluir</button></td></tr>${detalheRow(e)}`).join(''):'<tr><td colspan="5">Nenhum cliente encontrado.</td></tr>'}
|
||||
async function load(){try{const [pe,de]=await Promise.all([Api.get('/api/master/planos'),Api.get('/api/master/empresas')]);planos=pe.planos||[];empresas=de.empresas||[];renderPlanos();render()}catch(e){Util.toast(e.message,true)}}
|
||||
empresaForm.onsubmit=async e=>{e.preventDefault();try{await Api.post('/api/master/empresas',Util.formData(empresaForm));empresaForm.reset();Util.toast('Cliente cadastrado');load()}catch(err){Util.toast(err.message,true)}};
|
||||
busca.oninput=render;
|
||||
window.ver=id=>{selecionado=selecionado===id&&modo==='ver'?null:id;modo=selecionado?'ver':null;render()};
|
||||
window.editar=id=>{selecionado=selecionado===id&&modo==='editar'?null:id;modo=selecionado?'editar':null;render()};
|
||||
window.fecharDetalhe=()=>{selecionado=null;modo=null;render()};
|
||||
window.salvarEdicao=async(ev,id)=>{ev.preventDefault();try{await Api.put(`/api/master/empresas/${id}`,Util.formData(ev.target));Util.toast('Cliente atualizado');selecionado=null;modo=null;load()}catch(err){Util.toast(err.message,true)}};
|
||||
window.alterarStatus=async(id,status)=>{try{await Api.post(`/api/master/empresas/${id}/status`,{status});Util.toast(status==='ativa'?'Cliente ativado':'Cliente bloqueado');load()}catch(err){Util.toast(err.message,true)}};
|
||||
window.excluir=async id=>{try{await Api.request(`/api/master/empresas/${id}`,{method:'DELETE'});Util.toast('Cliente excluido');selecionado=null;modo=null;load()}catch(err){Util.toast(err.message,true)}};
|
||||
load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Configuracoes</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Configuracoes</h1></div><section class="panel"><h2>E-mail</h2><form id="email-form" class="grid cols"><div class="field"><label>Host</label><input name="smtp_host"></div><div class="field"><label>Porta</label><input name="smtp_port" type="number"></div><div class="field"><label>Usuario</label><input name="smtp_user"></div><div class="field"><label>Senha</label><input name="smtp_password" type="password"></div><div class="field"><label>Remetente</label><input name="smtp_from"></div><div class="field"><label>StartTLS</label><select name="smtp_starttls"><option value="true">Sim</option><option value="false">Nao</option></select></div><button class="btn">Salvar e-mail</button></form><div id="email-rows"></div></section><section class="panel"><h2>AbacatePay</h2><form id="abacate-form" class="grid cols"><div class="field"><label>API Key</label><input name="abacatepay_api_key" type="password" placeholder="Cole a chave da AbacatePay"></div><div class="field"><label>Webhook Secret</label><input name="abacatepay_webhook_secret" type="password" placeholder="Segredo do webhook"></div><button class="btn">Salvar AbacatePay</button></form><div id="abacate-rows"></div></section><section class="panel"><h2>Acessos WhatsApp</h2><div class="table-wrap"><table><thead><tr><th>Empresa</th><th>Numero</th><th>Status</th><th>Solicitado em</th><th>Acoes</th></tr></thead><tbody id="evolution-rows"></tbody></table></div></section></main><script>Layout.mount('master','configuracoes');</script><script src="/master/configuracoes.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
const emailForm=document.querySelector('#email-form'),abacateForm=document.querySelector('#abacate-form'),emailRows=document.querySelector('#email-rows'),abacateRows=document.querySelector('#abacate-rows'),evolutionRows=document.querySelector('#evolution-rows');let empresas=[];const emailKeys=['smtp_from','smtp_host','smtp_password','smtp_port','smtp_starttls','smtp_user'];const abacateKeys=['abacatepay_api_key','abacatepay_webhook_secret'];function renderConfig(target,cfg,keys,labels){target.innerHTML=keys.map(k=>`<p><strong>${labels[k]||k}</strong>: ${cfg[k]||''}</p>`).join('')}async function load(){try{const d=await Api.get('/api/master/configuracoes');const cfg=d.configuracoes||{};for(const [k,v] of Object.entries(cfg)){if(emailForm.elements[k])emailForm.elements[k].value=v;if(abacateForm.elements[k])abacateForm.elements[k].value=v}renderConfig(emailRows,cfg,emailKeys,{smtp_from:'Remetente',smtp_host:'Host',smtp_password:'Senha',smtp_port:'Porta',smtp_starttls:'StartTLS',smtp_user:'Usuario'});renderConfig(abacateRows,cfg,abacateKeys,{abacatepay_api_key:'API Key',abacatepay_webhook_secret:'Webhook Secret'});await loadEvolution()}catch(e){Util.toast(e.message,true)}}async function loadEvolution(){const d=await Api.get('/api/master/empresas');empresas=d.empresas||[];const grupos=await Promise.all(empresas.map(async e=>{try{const r=await Api.get(`/api/master/empresas/${e.id}/evolution-instancias`);return (r.instancias||[]).map(i=>({...i,empresa_nome:e.nome}))}catch(_){return[]}}));const instancias=grupos.flat();evolutionRows.innerHTML=instancias.length?instancias.map(i=>`<tr><td>${i.empresa_nome}</td><td>${i.numero_whatsapp||'-'}</td><td>${Util.badge(i.status)}</td><td>${Util.date(i.solicitado_em)}</td><td><button class="btn secondary" onclick="aprovar(${i.empresa_id},${i.id})">Aprovar</button> <button class="btn danger" onclick="rejeitar(${i.empresa_id},${i.id})">Rejeitar</button> <button class="btn danger" onclick="excluir(${i.empresa_id},${i.id})">Excluir</button></td></tr>`).join(''):'<tr><td colspan="5">Nenhuma solicitacao encontrada.</td></tr>'}async function aprovar(emp,id){try{await Api.post(`/api/master/empresas/${emp}/evolution-instancias/${id}/aprovar`,{});Util.toast('Acesso liberado');loadEvolution()}catch(e){Util.toast(e.message,true)}}async function rejeitar(emp,id){try{await Api.post(`/api/master/empresas/${emp}/evolution-instancias/${id}/rejeitar`,{});Util.toast('Acesso rejeitado');loadEvolution()}catch(e){Util.toast(e.message,true)}}async function excluir(emp,id){if(!confirm('Excluir esta conexao de WhatsApp?'))return;try{await Api.request(`/api/master/empresas/${emp}/evolution-instancias/${id}`,{method:'DELETE'});Util.toast('Conexao excluida');loadEvolution()}catch(e){Util.toast(e.message,true)}}emailForm.onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/master/configuracoes',Util.formData(emailForm));Util.toast('Configuracoes de e-mail salvas');load()}catch(err){Util.toast(err.message,true)}};abacateForm.onsubmit=async e=>{e.preventDefault();try{await Api.put('/api/master/configuracoes',Util.formData(abacateForm));Util.toast('Configuracoes AbacatePay salvas');load()}catch(err){Util.toast(err.message,true)}};load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Financeiro Master</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Financeiro Master</h1></div><section class="panel"><h2>Situacao das empresas</h2><div class="cards" id="cards"></div><div class="table-wrap"><table><thead><tr><th>Empresa</th><th>Status</th><th>Periodo de utilizacao</th><th>Vencimento</th><th>Acao</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>Layout.mount('master','financeiro');</script><script src="/master/financeiro.js"></script></body></html>
|
||||
@@ -0,0 +1,8 @@
|
||||
const rows=document.querySelector('#rows'),cards=document.querySelector('#cards');
|
||||
function data(v){if(!v)return'-';const d=new Date(v+'T00:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('pt-BR')}
|
||||
function periodo(e){return '<strong>'+data(e.data_inicio)+' a '+data(e.data_fim)+'</strong><br><span class="muted">Vencimento: '+data(e.data_vencimento)+'</span><br>'+Util.badge(e.periodo_status||'pendente')}
|
||||
async function load(){try{const d=await Api.get('/api/master/empresas');const es=d.empresas||[];cards.innerHTML='<div class="stat">Ativas<strong>'+es.filter(e=>e.status==='ativa').length+'</strong></div><div class="stat">Bloqueadas<strong>'+es.filter(e=>e.status==='bloqueada').length+'</strong></div><div class="stat">Pagas<strong>'+es.filter(e=>e.periodo_status==='pago').length+'</strong></div>';rows.innerHTML=es.map(e=>{const bloqueada=e.status==='bloqueada'||e.periodo_status==='bloqueado';const liberar=bloqueada?' <button class="btn" onclick="desbloquear('+e.id+')">Desbloquear</button>':'';return '<tr><td>'+e.nome+'</td><td>'+Util.badge(e.status)+'</td><td>'+periodo(e)+'</td><td><div class="field inline"><label>Dia vencimento</label><input type="number" min="1" max="28" value="'+(e.dia_vencimento||7)+'" onchange="salvarVencimento('+e.id+',this.value)"></div></td><td><button class="btn secondary" onclick="lembrar('+e.id+')">Lembrete</button>'+liberar+'</td></tr>'}).join('')}catch(e){Util.toast(e.message,true)}}
|
||||
async function salvarVencimento(id,dia){try{await Api.put('/api/master/empresas/'+id+'/vencimento',{dia_vencimento:Number(dia)});Util.toast('Vencimento atualizado');load()}catch(e){Util.toast(e.message,true)}}
|
||||
async function lembrar(id){try{await Api.post('/api/master/empresas/'+id+'/lembrete-cobranca',{canal:'email'});Util.toast('Lembrete registrado')}catch(e){Util.toast(e.message,true)}}
|
||||
async function desbloquear(id){if(!confirm('Desbloquear manualmente esta empresa e liberar o periodo atual?'))return;try{await Api.post('/api/master/empresas/'+id+'/desbloquear',{});Util.toast('Empresa desbloqueada');load()}catch(e){Util.toast(e.message,true)}}
|
||||
load();
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Planos</title><link rel="stylesheet" href="/css/app.css"><script src="/js/auth.js"></script><script src="/js/util.js"></script><script src="/js/api.js"></script><script src="/js/layout.js"></script></head><body><main><div class="topbar"><h1>Planos</h1></div><section class="panel"><h2>Novo plano</h2><form id="plano-form" class="row"><div class="field"><label>Nome</label><input name="nome" required></div><div class="field"><label>Valor em centavos</label><input name="valor_mensal_centavos" type="number" required></div><div class="field"><label>Descricao</label><input name="descricao"></div><div class="field"><label>Funcionalidades</label><input name="funcionalidades" placeholder="whatsapp_envio,email_envio"></div><button class="btn">Salvar</button></form></section><section class="panel"><h2>Planos cadastrados</h2><div class="table-wrap"><table><thead><tr><th>ID</th><th>Nome</th><th>Valor</th><th>Descricao</th><th>Status</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>Layout.mount('master','planos');</script><script src="/master/planos.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
const form=document.querySelector('#plano-form'),rows=document.querySelector('#rows');async function load(){try{const d=await Api.get('/api/master/planos');rows.innerHTML=(d.planos||[]).map(p=>`<tr><td>${p.id}</td><td>${p.nome}</td><td>${Util.money(p.valor_mensal_centavos)}</td><td>${p.descricao||''}</td><td>${Util.badge(p.ativo?'ativo':'inativo')}</td></tr>`).join('')}catch(e){Util.toast(e.message,true)}}form.onsubmit=async e=>{e.preventDefault();const b=Util.formData(form);b.funcionalidades=(b.funcionalidades||'').split(',').map(x=>x.trim()).filter(Boolean);try{await Api.post('/api/master/planos',b);form.reset();Util.toast('Plano salvo');load()}catch(err){Util.toast(err.message,true)}};load();
|
||||
Reference in New Issue
Block a user