feat(master): listar todas as conexoes da Evolution, com opcoes de reativar para cliente e excluir

This commit is contained in:
Tiago
2026-10-13 09:22:01 -03:00
parent 4437e19be5
commit a58ada2b34
6 changed files with 1053 additions and 3 deletions
@@ -6,6 +6,8 @@ import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.qrcode.QRCodeWriter;
import com.seconectai.condocontab.util.SimpleJson;
import java.io.IOException;
import java.io.ByteArrayOutputStream;
import java.net.URI;
@@ -14,8 +16,11 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class EvolutionApiClient {
@@ -29,6 +34,118 @@ public final class EvolutionApiClient {
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
public String defaultBaseUrl() {
return defaultBaseUrl;
}
public List<Map<String, Object>> listarInstancias(String baseUrl) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
}
if (mockMode(baseUrl)) {
return List.of();
}
HttpResponse<String> resp;
try {
resp = sendNoBody("GET", clean(baseUrl) + "/instance/fetchInstances", globalApiKey);
} catch (IOException e) {
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
if (msg.contains("404")) {
resp = sendNoBody("GET", clean(baseUrl) + "/instance/all", globalApiKey);
} else {
throw e;
}
}
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Evolution API retornou HTTP " + resp.statusCode() + ": " + resp.body());
}
return parseInstancesResponse(resp.body(), baseUrl);
}
private List<Map<String, Object>> parseInstancesResponse(String body, String baseUrl) {
if (body == null || body.isBlank()) {
return List.of();
}
Object parsed = SimpleJson.parse(body);
List<Map<String, Object>> out = new ArrayList<>();
if (parsed instanceof List<?> list) {
for (Object item : list) {
Map<String, Object> info = extrairInstanciaInfo(item, baseUrl);
if (info != null) {
out.add(info);
}
}
} else if (parsed instanceof Map<?, ?> map) {
Object inner = map.get("instances");
if (inner == null) inner = map.get("data");
if (inner instanceof List<?> list) {
for (Object item : list) {
Map<String, Object> info = extrairInstanciaInfo(item, baseUrl);
if (info != null) {
out.add(info);
}
}
} else {
Map<String, Object> info = extrairInstanciaInfo(map, baseUrl);
if (info != null) {
out.add(info);
}
}
}
return out;
}
private Map<String, Object> extrairInstanciaInfo(Object rawItem, String baseUrl) {
if (!(rawItem instanceof Map<?, ?> map)) {
return null;
}
Map<?, ?> data = map;
if (map.get("instance") instanceof Map<?, ?> inner) {
data = inner;
}
String name = firstNonBlank(str(data.get("instanceName")), firstNonBlank(str(data.get("name")), str(map.get("instanceName"))));
if (name == null || name.isBlank()) {
name = str(map.get("name"));
}
if (name == null || name.isBlank()) {
return null;
}
String state = firstNonBlank(str(data.get("connectionStatus")), firstNonBlank(str(data.get("status")), str(map.get("connectionStatus"))));
if (state == null || state.isBlank()) {
state = firstNonBlank(str(map.get("status")), "close");
}
String ownerRaw = firstNonBlank(str(data.get("owner")), firstNonBlank(str(data.get("ownerJid")), firstNonBlank(str(data.get("number")), str(map.get("owner")))));
if (ownerRaw == null || ownerRaw.isBlank()) {
ownerRaw = str(map.get("ownerJid"));
}
String numero = null;
if (ownerRaw != null && !ownerRaw.isBlank()) {
String digits = onlyDigits(ownerRaw);
if (!digits.isBlank()) {
numero = digits;
}
}
String profileName = firstNonBlank(str(data.get("profileName")), str(map.get("profileName")));
String profilePictureUrl = firstNonBlank(str(data.get("profilePictureUrl")), str(map.get("profilePictureUrl")));
Map<String, Object> out = new LinkedHashMap<>();
out.put("instance_name", name);
out.put("connection_status", state);
out.put("numero_whatsapp", numero);
out.put("profile_name", profileName);
out.put("profile_picture_url", profilePictureUrl);
out.put("instance_url", baseUrl);
return out;
}
private String str(Object o) {
return o == null ? null : String.valueOf(o).trim();
}
public CriacaoInstancia criarInstancia(String baseUrl, String instanceName, String token, String numero) throws IOException, InterruptedException {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = defaultBaseUrl;
@@ -119,6 +119,26 @@ public final class MasterHandlers implements HttpHandler {
excluirEvolutionInstancia(ex, parseId(parts[1]), parseId(parts[3]));
return;
}
if (parts.length == 1 && "evolution-instancias".equals(parts[0]) && "GET".equals(method)) {
listarTodasEvolutionInstancias(ex);
return;
}
if (parts.length == 2 && "evolution-instancias".equals(parts[0]) && "reativar".equals(parts[1]) && "POST".equals(method)) {
reativarEvolutionInstancia(ex);
return;
}
if (parts.length == 2 && "evolution-instancias".equals(parts[0]) && "DELETE".equals(method)) {
excluirEvolutionInstanciaPorNome(ex, parts[1]);
return;
}
if (parts.length == 3 && "evolution-instancias".equals(parts[0]) && "qrcode".equals(parts[2]) && "POST".equals(method)) {
obterQrCodeEvolutionPorNome(ex, parts[1]);
return;
}
if (parts.length == 3 && "evolution-instancias".equals(parts[0]) && "reiniciar".equals(parts[2]) && "POST".equals(method)) {
reiniciarEvolutionInstanciaPorNome(ex, parts[1]);
return;
}
if (parts.length == 1 && "configuracoes".equals(parts[0])) {
if ("GET".equals(method)) listarConfiguracoes(ex);
else if ("PUT".equals(method)) salvarConfiguracoes(ex);
@@ -340,6 +360,305 @@ public final class MasterHandlers implements HttpHandler {
}
}
private void listarTodasEvolutionInstancias(HttpExchange ex) throws Exception {
Map<Long, Map<String, Object>> empresasMap = new LinkedHashMap<>();
List<Map<String, Object>> empresasList = new ArrayList<>();
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("SELECT id, nome, slug, status FROM empresas WHERE status <> 'excluida' ORDER BY nome")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Map<String, Object> emp = resultMap(rs);
empresasMap.put(rs.getLong("id"), emp);
empresasList.add(emp);
}
}
}
List<Map<String, Object>> dbRows = new ArrayList<>();
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT ei.id, ei.empresa_id, ei.instance_url, ei.instance_token_enc, ei.numero_whatsapp, ei.status, ei.solicitado_em, ei.aprovado_em, ei.aprovado_por, e.nome AS empresa_nome " +
"FROM evolution_instancias ei " +
"LEFT JOIN empresas e ON e.id = ei.empresa_id " +
"ORDER BY ei.solicitado_em DESC, ei.id DESC")) {
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
dbRows.add(resultMap(rs));
}
}
}
List<Map<String, Object>> evoInstances = new ArrayList<>();
String evolutionErro = null;
try {
evoInstances = evolutionApiClient.listarInstancias(null);
} catch (Exception err) {
evolutionErro = err.getMessage();
}
Map<String, Map<String, Object>> combined = new LinkedHashMap<>();
Set<Long> matchedDbIds = new HashSet<>();
for (Map<String, Object> evo : evoInstances) {
String instName = str(evo.get("instance_name"));
if (instName == null || instName.isBlank()) continue;
Map<String, Object> item = new LinkedHashMap<>(evo);
item.put("origem_evolution", true);
Map<String, Object> matchedDb = null;
for (Map<String, Object> db : dbRows) {
String tokenEnc = str(db.get("instance_token_enc"));
if (tokenEnc != null) {
String nameInDb = tokenEnc.split(":", 2)[0];
if (instName.equalsIgnoreCase(nameInDb)) {
matchedDb = db;
break;
}
}
}
if (matchedDb != null) {
matchedDbIds.add(((Number) matchedDb.get("id")).longValue());
item.put("db_id", matchedDb.get("id"));
item.put("empresa_id", matchedDb.get("empresa_id"));
item.put("empresa_nome", matchedDb.get("empresa_nome"));
item.put("status", matchedDb.get("status"));
item.put("solicitado_em", matchedDb.get("solicitado_em"));
item.put("aprovado_em", matchedDb.get("aprovado_em"));
if ((item.get("numero_whatsapp") == null || str(item.get("numero_whatsapp")).isBlank()) && matchedDb.get("numero_whatsapp") != null) {
item.put("numero_whatsapp", matchedDb.get("numero_whatsapp"));
}
} else {
Long deducedEmpresaId = deduzirEmpresaIdPorNome(instName);
if (deducedEmpresaId != null && empresasMap.containsKey(deducedEmpresaId)) {
item.put("empresa_id", deducedEmpresaId);
item.put("empresa_nome", empresasMap.get(deducedEmpresaId).get("nome"));
item.put("status", "sem_vinculo");
} else {
item.put("empresa_id", null);
item.put("empresa_nome", "Não vinculado");
item.put("status", "sem_vinculo");
}
}
combined.put(instName.toLowerCase(Locale.ROOT), item);
}
for (Map<String, Object> db : dbRows) {
long dbId = ((Number) db.get("id")).longValue();
if (matchedDbIds.contains(dbId)) continue;
String tokenEnc = str(db.get("instance_token_enc"));
String instName = (tokenEnc != null && !tokenEnc.isBlank()) ? tokenEnc.split(":", 2)[0] : ("instancia_" + dbId);
String key = instName.toLowerCase(Locale.ROOT);
if (!combined.containsKey(key)) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("instance_name", instName);
item.put("connection_status", (evolutionErro != null) ? "evolution_indisponivel" : "nao_encontrada_na_evolution");
item.put("numero_whatsapp", db.get("numero_whatsapp"));
item.put("profile_name", null);
item.put("profile_picture_url", null);
item.put("instance_url", db.get("instance_url"));
item.put("db_id", db.get("id"));
item.put("empresa_id", db.get("empresa_id"));
item.put("empresa_nome", db.get("empresa_nome"));
item.put("status", db.get("status"));
item.put("solicitado_em", db.get("solicitado_em"));
item.put("aprovado_em", db.get("aprovado_em"));
item.put("origem_evolution", false);
combined.put(key, item);
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("instancias", new ArrayList<>(combined.values()));
response.put("empresas", empresasList);
response.put("evolution_status", (evolutionErro == null) ? "online" : "erro");
if (evolutionErro != null) {
response.put("evolution_erro", evolutionErro);
}
HttpResponses.json(ex, 200, response);
}
private void reativarEvolutionInstancia(HttpExchange ex) throws Exception {
Map<String, String> b = body(ex);
String instanceName = b.get("instance_name");
if (instanceName == null || instanceName.isBlank()) {
HttpResponses.json(ex, 400, Map.of("erro", "nome_instancia_obrigatorio"));
return;
}
long empresaId;
try {
empresaId = Long.parseLong(req(b, "empresa_id"));
} catch (Exception e) {
HttpResponses.json(ex, 400, Map.of("erro", "empresa_id_invalida"));
return;
}
String empresaNome;
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("SELECT nome FROM empresas WHERE id=? AND status<>'excluida'")) {
ps.setLong(1, empresaId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
HttpResponses.json(ex, 404, Map.of("erro", "empresa_nao_encontrada"));
return;
}
empresaNome = rs.getString("nome");
}
}
String numero = EvolutionApiClient.onlyDigits(b.get("numero_whatsapp"));
String defaultUrl = evolutionApiClient.defaultBaseUrl();
try (Connection conn = dataSourceProvider.getMasterConnection()) {
conn.setAutoCommit(false);
try {
if (numero != null && !numero.isBlank()) {
try (PreparedStatement ps = conn.prepareStatement(
"DELETE FROM evolution_instancias WHERE numero_whatsapp=? AND (instance_token_enc NOT LIKE ? OR instance_token_enc IS NULL)")) {
ps.setString(1, numero);
ps.setString(2, instanceName + "%");
ps.executeUpdate();
}
}
Long existingId = null;
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, numero_whatsapp FROM evolution_instancias WHERE instance_token_enc LIKE ? OR instance_token_enc=? ORDER BY id DESC LIMIT 1")) {
ps.setString(1, instanceName + ":%");
ps.setString(2, instanceName);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
existingId = rs.getLong("id");
if ((numero == null || numero.isBlank()) && rs.getString("numero_whatsapp") != null) {
numero = rs.getString("numero_whatsapp");
}
}
}
}
if (existingId != null) {
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE evolution_instancias SET empresa_id=?, status='aprovada', aprovado_em=now(), aprovado_por=NULL, numero_whatsapp=COALESCE(NULLIF(?, ''), numero_whatsapp) WHERE id=?")) {
ps.setLong(1, empresaId);
ps.setString(2, numero == null ? "" : numero);
ps.setLong(3, existingId);
ps.executeUpdate();
}
} else {
String tokenEnc = instanceName + ":" + UUID.randomUUID().toString().replace("-", "");
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO evolution_instancias (empresa_id, instance_url, instance_token_enc, numero_whatsapp, status, solicitado_em, aprovado_em) VALUES (?, ?, ?, NULLIF(?, ''), 'aprovada', now(), now())")) {
ps.setLong(1, empresaId);
ps.setString(2, defaultUrl);
ps.setString(3, tokenEnc);
ps.setString(4, numero == null ? "" : numero);
ps.executeUpdate();
}
}
ativarFuncionalidade(conn, empresaId, "whatsapp_envio");
conn.commit();
} catch (Exception err) {
conn.rollback();
throw err;
} finally {
conn.setAutoCommit(true);
}
}
try {
evolutionApiClient.reiniciar(defaultUrl, instanceName);
} catch (Exception ignored) {}
HttpResponses.json(ex, 200, Map.of("ok", true, "mensagem", "Conexão de WhatsApp reativada com sucesso para " + empresaNome));
}
private void excluirEvolutionInstanciaPorNome(HttpExchange ex, String instanceName) throws Exception {
if (instanceName == null || instanceName.isBlank()) {
HttpResponses.json(ex, 400, Map.of("erro", "nome_instancia_obrigatorio"));
return;
}
String defaultUrl = evolutionApiClient.defaultBaseUrl();
String evolutionErro = null;
try {
evolutionApiClient.excluirInstancia(defaultUrl, instanceName);
} catch (Exception err) {
evolutionErro = err.getMessage();
}
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("DELETE FROM evolution_instancias WHERE instance_token_enc LIKE ? OR instance_token_enc=?")) {
ps.setString(1, instanceName + ":%");
ps.setString(2, instanceName);
ps.executeUpdate();
}
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("ok", true);
resp.put("mensagem", "Instância '" + instanceName + "' excluída com sucesso.");
if (evolutionErro != null) {
resp.put("aviso_evolution", evolutionErro);
}
HttpResponses.json(ex, 200, resp);
}
private void obterQrCodeEvolutionPorNome(HttpExchange ex, String instanceName) throws Exception {
if (instanceName == null || instanceName.isBlank()) {
HttpResponses.json(ex, 400, Map.of("erro", "nome_instancia_obrigatorio"));
return;
}
String numero = "";
try (Connection conn = dataSourceProvider.getMasterConnection();
PreparedStatement ps = conn.prepareStatement("SELECT numero_whatsapp FROM evolution_instancias WHERE instance_token_enc LIKE ? OR instance_token_enc=? ORDER BY id DESC LIMIT 1")) {
ps.setString(1, instanceName + ":%");
ps.setString(2, instanceName);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next() && rs.getString("numero_whatsapp") != null) {
numero = rs.getString("numero_whatsapp");
}
}
}
String defaultUrl = evolutionApiClient.defaultBaseUrl();
Map<String, Object> qr = evolutionApiClient.conectar(defaultUrl, instanceName, numero);
HttpResponses.json(ex, 200, qr);
}
private void reiniciarEvolutionInstanciaPorNome(HttpExchange ex, String instanceName) throws Exception {
if (instanceName == null || instanceName.isBlank()) {
HttpResponses.json(ex, 400, Map.of("erro", "nome_instancia_obrigatorio"));
return;
}
String defaultUrl = evolutionApiClient.defaultBaseUrl();
Map<String, Object> res = evolutionApiClient.reiniciar(defaultUrl, instanceName);
HttpResponses.json(ex, 200, res);
}
private Long deduzirEmpresaIdPorNome(String name) {
if (name == null) return null;
if (name.startsWith("empresa")) {
int underscore = name.indexOf('_');
String idPart = (underscore > 7) ? name.substring(7, underscore) : name.substring(7);
try {
return Long.parseLong(idPart);
} catch (NumberFormatException ignored) {}
}
return null;
}
private String req(Map<String, String> m, String k) {
String v = m.get(k);
if (v == null || v.isBlank()) throw new IllegalArgumentException("Campo obrigatório: " + k);
return v;
}
private String str(Object o) {
return o == null ? null : String.valueOf(o).trim();
}
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)
@@ -1,6 +1,8 @@
package com.seconectai.condocontab.util;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class SimpleJson {
@@ -12,6 +14,14 @@ public final class SimpleJson {
return parser.parseObject();
}
public static Object parse(String json) {
if (json == null || json.isBlank()) {
return null;
}
Parser parser = new Parser(json);
return parser.parseValue();
}
private static final class Parser {
private final String text;
private int pos;
@@ -62,6 +72,145 @@ public final class SimpleJson {
return text.substring(start, pos).trim();
}
private Object parseValue() {
skipWs();
char c = peek();
if (c == '{') {
return parseGenericMap();
}
if (c == '[') {
return parseGenericList();
}
if (c == '"') {
return parseString();
}
if (c == 't' || c == 'T') {
expectWord("true");
return Boolean.TRUE;
}
if (c == 'f' || c == 'F') {
expectWord("false");
return Boolean.FALSE;
}
if (c == 'n' || c == 'N') {
expectWord("null");
return null;
}
if (c == '-' || (c >= '0' && c <= '9')) {
return parseNumber();
}
return parseBareWord();
}
private Map<String, Object> parseGenericMap() {
Map<String, Object> map = new LinkedHashMap<>();
expect('{');
skipWs();
if (peek() == '}') {
pos++;
return map;
}
while (true) {
skipWs();
String key = parseString();
skipWs();
expect(':');
skipWs();
map.put(key, parseValue());
skipWs();
char c = peek();
if (c == ',') {
pos++;
continue;
}
if (c == '}') {
pos++;
return map;
}
throw new IllegalArgumentException("JSON invalido na posicao " + pos);
}
}
private List<Object> parseGenericList() {
List<Object> list = new ArrayList<>();
expect('[');
skipWs();
if (peek() == ']') {
pos++;
return list;
}
while (true) {
skipWs();
list.add(parseValue());
skipWs();
char c = peek();
if (c == ',') {
pos++;
continue;
}
if (c == ']') {
pos++;
return list;
}
throw new IllegalArgumentException("JSON invalido na posicao " + pos);
}
}
private Object parseNumber() {
int start = pos;
if (peek() == '-') pos++;
while (pos < text.length() && Character.isDigit(text.charAt(pos))) {
pos++;
}
boolean isDecimal = false;
if (pos < text.length() && text.charAt(pos) == '.') {
isDecimal = true;
pos++;
while (pos < text.length() && Character.isDigit(text.charAt(pos))) {
pos++;
}
}
if (pos < text.length() && (text.charAt(pos) == 'e' || text.charAt(pos) == 'E')) {
isDecimal = true;
pos++;
if (pos < text.length() && (text.charAt(pos) == '+' || text.charAt(pos) == '-')) {
pos++;
}
while (pos < text.length() && Character.isDigit(text.charAt(pos))) {
pos++;
}
}
String raw = text.substring(start, pos);
if (isDecimal) {
try {
return Double.parseDouble(raw);
} catch (NumberFormatException ignored) {}
} else {
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {}
}
return raw;
}
private String parseBareWord() {
int start = pos;
while (pos < text.length() && ",}]".indexOf(text.charAt(pos)) < 0) {
pos++;
}
return text.substring(start, pos).trim();
}
private void expectWord(String word) {
for (int i = 0; i < word.length(); i++) {
if (pos < text.length() && Character.toLowerCase(text.charAt(pos)) == Character.toLowerCase(word.charAt(i))) {
pos++;
} else {
return;
}
}
}
private String parseString() {
expect('"');
StringBuilder out = new StringBuilder();
@@ -84,7 +233,19 @@ public final class SimpleJson {
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
default -> throw new IllegalArgumentException("Escape JSON nao suportado: " + escaped);
case 'u' -> {
if (pos + 4 > text.length()) {
throw new IllegalArgumentException("Unicode escape incompleto");
}
String hex = text.substring(pos, pos + 4);
pos += 4;
try {
out.append((char) Integer.parseInt(hex, 16));
} catch (NumberFormatException e) {
out.append("\\u").append(hex);
}
}
default -> out.append(escaped);
}
} else {
out.append(c);
+5
View File
@@ -628,6 +628,11 @@ th {
color: var(--warn);
}
.badge.danger {
background: var(--danger-soft);
color: var(--danger);
}
.toast,
.finance-banner {
position: fixed;
+135 -1
View File
@@ -1 +1,135 @@
<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Configurações</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>Configurações</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>Usuário</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">Não</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>Número</th><th>Status</th><th>Solicitado em</th><th>Ações</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>
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Configurações</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>Configurações</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>Usuário</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">Não</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">
<div class="row" style="align-items: center; justify-content: space-between; margin-bottom: 16px;">
<h2>Acessos WhatsApp (Evolution API)</h2>
<div class="row" style="align-items: center; gap: 8px;">
<span id="evolution-status-badge"></span>
<button class="btn secondary" type="button" onclick="loadEvolution()">Atualizar</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Instância</th>
<th>Empresa / Cliente</th>
<th>Número</th>
<th>Status Conexão</th>
<th>Status Plataforma</th>
<th>Ações</th>
</tr>
</thead>
<tbody id="evolution-rows"></tbody>
</table>
</div>
</section>
<!-- Modal Reativar para Cliente -->
<div id="reativar-modal" class="modal hidden">
<div class="modal-backdrop" onclick="fecharModalReativar()"></div>
<section class="modal-panel" style="max-width: 520px;">
<div class="modal-head">
<h2>Reativar Conexão WhatsApp</h2>
<button class="btn secondary" type="button" onclick="fecharModalReativar()">✕</button>
</div>
<form id="reativar-form" onsubmit="salvarReativacao(event)">
<div class="field" style="margin-bottom: 14px;">
<label>Instância da Evolution</label>
<input id="reativar-instance-name" name="instance_name" readonly style="background: var(--bg-soft, #f4f6f8); font-weight: 700;">
</div>
<div class="field" style="margin-bottom: 14px;">
<label>Cliente / Condomínio Destino</label>
<select id="reativar-empresa-id" name="empresa_id" required>
<option value="">Selecione um cliente...</option>
</select>
</div>
<div class="field" style="margin-bottom: 16px;">
<label>Número WhatsApp (opcional - com DDD)</label>
<input id="reativar-numero" name="numero_whatsapp" placeholder="Ex: 5511999999999">
<small style="color: var(--muted-strong); font-size: 12px; margin-top: 4px; display: block;">
Deixe em branco para manter o número já detectado na instância ou caso o cliente vá conectar via QR Code.
</small>
</div>
<div class="modal-actions">
<button class="btn secondary" type="button" onclick="fecharModalReativar()">Cancelar</button>
<button class="btn" type="submit">Reativar para este Cliente</button>
</div>
</form>
</section>
</div>
<!-- Modal QR Code -->
<div id="qr-modal" class="modal hidden">
<div class="modal-backdrop" onclick="fecharModalQr()"></div>
<section class="modal-panel" style="max-width: 440px; text-align: center;">
<div class="modal-head">
<h2 id="qr-modal-title">QR Code WhatsApp</h2>
<button class="btn secondary" type="button" onclick="fecharModalQr()">✕</button>
</div>
<div style="padding: 16px 0;">
<p id="qr-modal-desc" style="margin-bottom: 12px; font-size: 14px; color: var(--muted-strong);">
Escaneie com o WhatsApp no celular para conectar:
</p>
<div id="qr-loading" style="padding: 24px; color: var(--muted-strong);">Carregando QR Code...</div>
<img id="qr-modal-img" class="qr hidden" alt="QR Code" style="margin: 0 auto; max-width: 260px; display: block; border-radius: 8px;">
<div id="qr-pairing-box" class="field hidden" style="margin-top: 16px; text-align: left;">
<label>Código de pareamento</label>
<input id="qr-modal-pairing" readonly style="font-size: 16px; font-weight: bold; text-align: center; letter-spacing: 2px;">
</div>
</div>
<div class="modal-actions" style="justify-content: center;">
<button class="btn secondary" type="button" onclick="fecharModalQr()">Fechar</button>
</div>
</section>
</div>
</main>
<script>Layout.mount('master','configuracoes');</script>
<script src="/master/configuracoes.js"></script>
</body>
</html>
+315 -1
View File
@@ -1 +1,315 @@
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:'Usuário'});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 solicitação 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(!await Util.confirm('Excluir esta conexão de WhatsApp?','Excluir conexão'))return;try{await Api.request(`/api/master/empresas/${emp}/evolution-instancias/${id}`,{method:'DELETE'});Util.toast('Conexão excluída');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('Configurações 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('Configurações da AbacatePay salvas');load()}catch(err){Util.toast(err.message,true)}};load();
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'),
evolutionStatusBadge = document.querySelector('#evolution-status-badge'),
reativarModal = document.querySelector('#reativar-modal'),
reativarForm = document.querySelector('#reativar-form'),
reativarEmpresaSelect = document.querySelector('#reativar-empresa-id'),
qrModal = document.querySelector('#qr-modal'),
qrModalImg = document.querySelector('#qr-modal-img'),
qrLoading = document.querySelector('#qr-loading'),
qrPairingBox = document.querySelector('#qr-pairing-box'),
qrModalPairing = document.querySelector('#qr-modal-pairing'),
qrModalTitle = document.querySelector('#qr-modal-title');
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('');
}
function formatPhone(raw) {
if (!raw) return '<span class="badge warn">Não conectado</span>';
const d = String(raw).replace(/\D/g, '');
if (d.length === 13 && d.startsWith('55')) {
return `+55 (${d.slice(2, 4)}) ${d.slice(4, 9)}-${d.slice(9)}`;
}
if (d.length === 12 && d.startsWith('55')) {
return `+55 (${d.slice(2, 4)}) ${d.slice(4, 8)}-${d.slice(8)}`;
}
if (d.length === 11) {
return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
}
if (d.length === 10) {
return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
}
return d || '<span class="badge warn">Não conectado</span>';
}
function connectionBadge(state) {
const s = String(state || '').toLowerCase();
if (s === 'open' || s === 'conectado') {
return '<span class="badge ok">Conectado (open)</span>';
}
if (s === 'close' || s === 'desconectado') {
return '<span class="badge warn">Desconectado (close)</span>';
}
if (s === 'connecting' || s === 'conectando') {
return '<span class="badge warn">Conectando</span>';
}
if (s === 'nao_encontrada_na_evolution') {
return '<span class="badge">Inexistente na Evolution</span>';
}
if (s === 'evolution_indisponivel') {
return '<span class="badge warn">Evolution Indisponível</span>';
}
return `<span class="badge">${state || '-'}</span>`;
}
function platformBadge(status) {
const s = String(status || '').toLowerCase();
if (s === 'aprovada' || s === 'ativa') {
return '<span class="badge ok">Aprovada</span>';
}
if (s === 'pendente') {
return '<span class="badge warn">Pendente</span>';
}
if (s === 'rejeitada') {
return '<span class="badge">Rejeitada</span>';
}
if (s === 'sem_vinculo') {
return '<span class="badge">Sem vínculo</span>';
}
return Util.badge(status);
}
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: 'Usuário'
});
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() {
evolutionRows.innerHTML = '<tr><td colspan="6" style="text-align:center; padding: 20px;">Carregando conexões da Evolution...</td></tr>';
try {
const d = await Api.get('/api/master/evolution-instancias');
empresas = d.empresas || [];
const instancias = d.instancias || [];
if (evolutionStatusBadge) {
if (d.evolution_status === 'online') {
evolutionStatusBadge.innerHTML = '<span class="badge ok">Evolution Conectada</span>';
} else {
evolutionStatusBadge.innerHTML = `<span class="badge warn" title="${d.evolution_erro || ''}">Evolution Offline/Aviso</span>`;
}
}
if (!instancias.length) {
evolutionRows.innerHTML = '<tr><td colspan="6">Nenhuma conexão de WhatsApp encontrada na Evolution ou no sistema.</td></tr>';
return;
}
evolutionRows.innerHTML = instancias.map(i => {
const instName = i.instance_name || '-';
const safeName = String(instName).replace(/'/g, "\\'");
const empresaNome = i.empresa_nome ? `<strong>${i.empresa_nome}</strong>` : '<span class="badge">Não vinculado</span>';
const numFmt = formatPhone(i.numero_whatsapp);
const connBadge = connectionBadge(i.connection_status);
const platBadge = platformBadge(i.status);
let btns = [];
// Botão Reativar para Cliente (sempre disponível)
btns.push(`<button class="btn secondary" type="button" onclick="abrirModalReativar('${safeName}', ${i.empresa_id || 'null'}, '${i.numero_whatsapp || ''}')">Reativar</button>`);
// Se pendente com empresa vinculada, botão aprovar rápido
if (i.status === 'pendente' && i.empresa_id && i.db_id) {
btns.push(`<button class="btn secondary" type="button" onclick="aprovar(${i.empresa_id}, ${i.db_id})">Aprovar</button>`);
}
// Se não conectado na Evolution, botões de QR Code e Reiniciar
if (String(i.connection_status).toLowerCase() !== 'open') {
btns.push(`<button class="btn secondary" type="button" onclick="verQrCode('${safeName}')">QR Code</button>`);
btns.push(`<button class="btn secondary" type="button" onclick="reiniciarEvolution('${safeName}')">Reiniciar</button>`);
}
// Botão Excluir da Evolution (sempre disponível)
btns.push(`<button class="btn danger" type="button" onclick="excluirEvolution('${safeName}')">Excluir da Evolution</button>`);
return `<tr>
<td><code>${instName}</code></td>
<td>${empresaNome}</td>
<td>${numFmt}</td>
<td>${connBadge}</td>
<td>${platBadge}</td>
<td style="white-space: nowrap;">${btns.join(' ')}</td>
</tr>`;
}).join('');
} catch (err) {
if (evolutionStatusBadge) {
evolutionStatusBadge.innerHTML = '<span class="badge warn">Falha ao carregar</span>';
}
evolutionRows.innerHTML = `<tr><td colspan="6" style="color:var(--danger)">Erro ao carregar conexões da Evolution: ${err.message}</td></tr>`;
Util.toast(err.message, true);
}
}
function abrirModalReativar(instanceName, empresaId, numero) {
document.querySelector('#reativar-instance-name').value = instanceName;
document.querySelector('#reativar-numero').value = numero || '';
reativarEmpresaSelect.innerHTML = '<option value="">Selecione um cliente...</option>' +
empresas.map(e => `<option value="${e.id}" ${empresaId == e.id ? 'selected' : ''}>${e.nome}</option>`).join('');
reativarModal.classList.remove('hidden');
}
function fecharModalReativar() {
reativarModal.classList.add('hidden');
}
async function salvarReativacao(e) {
e.preventDefault();
const form = e.target;
const instanceName = form.instance_name.value;
const empresaId = form.empresa_id.value;
const numero = form.numero_whatsapp.value;
if (!empresaId) {
Util.toast('Selecione um cliente para vincular a conexão', true);
return;
}
try {
const res = await Api.post('/api/master/evolution-instancias/reativar', {
instance_name: instanceName,
empresa_id: empresaId,
numero_whatsapp: numero
});
Util.toast(res.mensagem || 'Conexão reativada com sucesso');
fecharModalReativar();
loadEvolution();
} catch (err) {
Util.toast(err.message, true);
}
}
async function excluirEvolution(instanceName) {
const ok = await Util.confirm(
`Tem certeza que deseja excluir a conexão "${instanceName}" da Evolution API? Esta ação removerá a instância definitivamente.`,
'Excluir da Evolution'
);
if (!ok) return;
try {
const res = await Api.request(`/api/master/evolution-instancias/${encodeURIComponent(instanceName)}`, {
method: 'DELETE'
});
Util.toast(res.mensagem || 'Instância excluída com sucesso');
loadEvolution();
} catch (err) {
Util.toast(err.message, true);
}
}
async function reiniciarEvolution(instanceName) {
try {
await Api.post(`/api/master/evolution-instancias/${encodeURIComponent(instanceName)}/reiniciar`, {});
Util.toast('Instância reiniciada na Evolution');
setTimeout(loadEvolution, 1500);
} catch (err) {
Util.toast(err.message, true);
}
}
async function verQrCode(instanceName) {
qrModalTitle.textContent = `QR Code: ${instanceName}`;
qrModalImg.classList.add('hidden');
qrPairingBox.classList.add('hidden');
qrLoading.classList.remove('hidden');
qrLoading.textContent = 'Solicitando QR Code à Evolution...';
qrModal.classList.remove('hidden');
try {
const d = await Api.post(`/api/master/evolution-instancias/${encodeURIComponent(instanceName)}/qrcode`, {});
qrLoading.classList.add('hidden');
const base64 = d.qrcode_base64 || d.qr_code_base64;
if (base64) {
qrModalImg.src = base64.startsWith('data:image/') ? base64 : ('data:image/png;base64,' + base64);
qrModalImg.classList.remove('hidden');
} else {
qrLoading.textContent = 'Evolution não gerou imagem de QR Code nesta tentativa. Tente clicar em Reiniciar.';
qrLoading.classList.remove('hidden');
}
if (d.pairing_code) {
qrModalPairing.value = d.pairing_code;
qrPairingBox.classList.remove('hidden');
}
} catch (err) {
qrLoading.textContent = `Erro ao obter QR Code: ${err.message}`;
Util.toast(err.message, true);
}
}
function fecharModalQr() {
qrModal.classList.add('hidden');
}
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);
}
}
emailForm.onsubmit = async e => {
e.preventDefault();
try {
await Api.put('/api/master/configuracoes', Util.formData(emailForm));
Util.toast('Configurações 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('Configurações da AbacatePay salvas');
load();
} catch (err) {
Util.toast(err.message, true);
}
};
load();