diff --git a/.env.production.example b/.env.production.example index 9b661a9..84df7b1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -11,6 +11,7 @@ ABACATEPAY_WEBHOOK_SECRET=troque-por-um-segredo-do-webhook # Chave global da Evolution API. Use valor forte e igual ao configurado no app. EVOLUTION_GLOBAL_API_KEY=troque-por-uma-chave-forte +EVOLUTION_WEBHOOK_SECRET=troque-por-outro-segredo-forte EVOLUTION_API_URL=http://evolution-api:8080 EVOLUTION_PUBLIC_URL=https://condominio.seconectai.com.br/evolution diff --git a/DEPLOY.md b/DEPLOY.md index 64e9005..f4e3fd6 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -58,6 +58,7 @@ Ele gera automaticamente: - `DB_PASSWORD` - `JWT_SECRET` - `EVOLUTION_GLOBAL_API_KEY` +- `EVOLUTION_WEBHOOK_SECRET` para proteger o webhook de respostas da Evolution - `ABACATEPAY_WEBHOOK_SECRET` - `EVOLUTION_DATABASE_CONNECTION_URI` com senha URL-encoded - `MASTER_PASSWORD_HASH` a partir da senha Master inicial diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bc688c0..1179946 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -82,6 +82,7 @@ services: ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-} EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-} EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080} + EVOLUTION_WEBHOOK_SECRET: ${EVOLUTION_WEBHOOK_SECRET:-} depends_on: postgres: condition: service_healthy @@ -108,6 +109,7 @@ services: ABACATEPAY_API_KEY: ${ABACATEPAY_API_KEY:-} EVOLUTION_GLOBAL_API_KEY: ${EVOLUTION_GLOBAL_API_KEY:-} EVOLUTION_API_URL: ${EVOLUTION_API_URL:-http://evolution-api:8080} + EVOLUTION_WEBHOOK_SECRET: ${EVOLUTION_WEBHOOK_SECRET:-} depends_on: postgres: condition: service_healthy diff --git a/scripts/install-prod.sh b/scripts/install-prod.sh index 17651c3..9de219a 100755 --- a/scripts/install-prod.sh +++ b/scripts/install-prod.sh @@ -42,6 +42,7 @@ hash_master() { DB_PASSWORD="$(secret)" JWT_SECRET="$(secret)" EVOLUTION_GLOBAL_API_KEY="$(secret)" +EVOLUTION_WEBHOOK_SECRET="$(secret)" ABACATEPAY_WEBHOOK_SECRET="$(secret)" MASTER_PASSWORD_HASH="$(hash_master "$MASTER_PASSWORD")" DB_PASSWORD_URL="$(urlencode "$DB_PASSWORD")" @@ -57,6 +58,7 @@ ABACATEPAY_API_KEY= ABACATEPAY_WEBHOOK_SECRET=$ABACATEPAY_WEBHOOK_SECRET EVOLUTION_GLOBAL_API_KEY=$EVOLUTION_GLOBAL_API_KEY +EVOLUTION_WEBHOOK_SECRET=$EVOLUTION_WEBHOOK_SECRET EVOLUTION_API_URL=http://evolution-api:8080 EVOLUTION_PUBLIC_URL=https://$DOMAIN/evolution EVOLUTION_DATABASE_CONNECTION_URI=postgresql://app:$DB_PASSWORD_URL@postgres:5432/evolution @@ -71,5 +73,6 @@ echo "Instalacao concluida." echo "URL: https://$DOMAIN" echo "Health: https://$DOMAIN/health" echo "Webhook AbacatePay: https://$DOMAIN/api/master/webhooks/abacatepay?webhookSecret=$ABACATEPAY_WEBHOOK_SECRET" +echo "Webhook Evolution respostas: https://$DOMAIN/api/webhooks/evolution/respostas com header X-Webhook-Secret: $EVOLUTION_WEBHOOK_SECRET" echo echo "Guarde a senha Master informada. Os demais segredos foram gerados em $ENV_FILE." diff --git a/src/main/java/com/seconectai/condocontab/Main.java b/src/main/java/com/seconectai/condocontab/Main.java index e6687ab..8e1b2c1 100644 --- a/src/main/java/com/seconectai/condocontab/Main.java +++ b/src/main/java/com/seconectai/condocontab/Main.java @@ -16,6 +16,7 @@ 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.integracao.ExternalBridgeHandler; import com.seconectai.condocontab.master.http.MasterHandlers; import com.seconectai.condocontab.tenant.TenantDataSourceProvider; import com.seconectai.condocontab.tenant.http.ClienteHandlers; @@ -57,6 +58,7 @@ public final class Main { ClienteHandlers clienteHandlers = new ClienteHandlers(tenantDataSourceProvider, dataSourceProvider, Path.of("/app/uploads"), evolutionApiClient, emailClient); FinanceiroHandlers financeiroHandlers = new FinanceiroHandlers(dataSourceProvider, abacatePayClient); AbacatePayWebhookHandler abacatePayWebhookHandler = new AbacatePayWebhookHandler(dataSourceProvider, abacatePayClient); + ExternalBridgeHandler externalBridgeHandler = new ExternalBridgeHandler(tenantDataSourceProvider, dataSourceProvider, evolutionApiClient); if ("worker".equalsIgnoreCase(System.getenv().getOrDefault("APP_MODE", "web"))) { Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { @@ -77,6 +79,8 @@ public final class Main { router.post("/api/master/login", authHandlers.masterLogin()); router.post("/api/auth/login", authHandlers.funcionarioLogin()); router.post("/api/master/webhooks/abacatepay", abacatePayWebhookHandler); + router.post("/api/webhooks/evolution/respostas", externalBridgeHandler); + router.post("/api/integracao/mensagens", authMiddleware.exigirApiKey(externalBridgeHandler)); router.prefix("/api/master/", authMiddleware.exigirMaster(masterHandlers)); router.prefix("/api/financeiro/", authMiddleware.exigirFuncionario(financeiroHandlers)); router.prefix("/api/", authMiddleware.exigirFuncionario(clienteHandlers)); diff --git a/src/main/java/com/seconectai/condocontab/integracao/ExternalBridgeHandler.java b/src/main/java/com/seconectai/condocontab/integracao/ExternalBridgeHandler.java new file mode 100644 index 0000000..a27f3e7 --- /dev/null +++ b/src/main/java/com/seconectai/condocontab/integracao/ExternalBridgeHandler.java @@ -0,0 +1,231 @@ +package com.seconectai.condocontab.integracao; + +import com.seconectai.condocontab.auth.ApiKeyService; +import com.seconectai.condocontab.auth.http.AuthMiddleware; +import com.seconectai.condocontab.config.DataSourceProvider; +import com.seconectai.condocontab.http.HttpResponses; +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 javax.crypto.Mac; +import java.security.MessageDigest; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.net.InetAddress; +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.*; +import java.time.Duration; +import java.util.*; +import java.util.HexFormat; + +public final class ExternalBridgeHandler implements HttpHandler { + private final TenantDataSourceProvider tenantDs; + private final DataSourceProvider masterDs; + private final EvolutionApiClient evolutionApiClient; + private final String webhookSecret; + private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + public ExternalBridgeHandler(TenantDataSourceProvider tenantDs, DataSourceProvider masterDs, EvolutionApiClient evolutionApiClient) { + this.tenantDs = tenantDs; + this.masterDs = masterDs; + this.evolutionApiClient = evolutionApiClient; + this.webhookSecret = firstNonBlank(System.getenv("EVOLUTION_WEBHOOK_SECRET"), System.getenv("EVOLUTION_GLOBAL_API_KEY")); + } + + @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 (Exception e) { + e.printStackTrace(System.err); + HttpResponses.json(ex, 500, Map.of("erro", "erro_integracao", "mensagem", String.valueOf(e.getMessage()))); + } + } + + private void route(HttpExchange ex) throws Exception { + String path = ex.getRequestURI().getPath(); + String method = ex.getRequestMethod(); + if ("/api/integracao/mensagens".equals(path) && "POST".equals(method)) { + enviarMensagem(ex, (long) ex.getAttribute(AuthMiddleware.ATTR_EMPRESA_ID)); + return; + } + if ("/api/webhooks/evolution/respostas".equals(path) && "POST".equals(method)) { + receberRespostaEvolution(ex); + return; + } + HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado")); + } + + private void enviarMensagem(HttpExchange ex, long empresaId) throws Exception { + Map b = SimpleJson.parseObject(rawBody(ex)); + String mensagem = required(b, "mensagem"); + String destinatariosRaw = required(b, "destinatarios"); + ApiKeyConfig cfg = apiKeyConfig(ex.getRequestHeaders().getFirst("X-API-Key"), empresaId); + Map inst = evolutionAprovada(empresaId); + if (inst == null) { + HttpResponses.json(ex, 400, Map.of("erro", "whatsapp_indisponivel", "mensagem", "WhatsApp nao aprovado para esta empresa.")); + return; + } + List destinatarios = resolverDestinatarios(empresaId, destinatariosRaw); + if (destinatarios.isEmpty()) throw new IllegalArgumentException("Nenhum destinatario encontrado."); + String[] parts = String.valueOf(inst.get("instance_token_enc")).split(":", 2); + List> resultados = new ArrayList<>(); + try (Connection m = masterDs.getMasterConnection()) { + for (Destinatario d : destinatarios) { + String telefone = telefoneWhatsapp(d.telefone()); + Map row = new LinkedHashMap<>(); + row.put("tipo", d.tipo()); row.put("id", d.id()); row.put("nome", d.nome()); row.put("telefone", telefone); + try { + if (telefone.length() < 12) throw new IllegalArgumentException("telefone_invalido"); + String resp = evolutionApiClient.enviarTexto(str(inst.get("instance_url")), parts[0], parts.length > 1 ? parts[1] : "", telefone, mensagem); + long msgId = registrarMensagem(m, empresaId, cfg, b.get("referencia_externa"), d, telefone, mensagem, "enviado", resp); + row.put("status", "enviado"); row.put("mensagem_id", msgId); + } catch (Exception err) { + long msgId = registrarMensagem(m, empresaId, cfg, b.get("referencia_externa"), d, telefone, mensagem, "falhou", err.getMessage()); + row.put("status", "falhou"); row.put("erro", err.getMessage()); row.put("mensagem_id", msgId); + } + resultados.add(row); + } + } + HttpResponses.json(ex, 200, Map.of("ok", true, "resultados", resultados)); + } + + private void receberRespostaEvolution(HttpExchange ex) throws Exception { + validarWebhookEvolution(ex); + String raw = rawBody(ex); + String numero = telefoneWhatsapp(firstNonBlank(extract(raw, "remoteJid"), extract(raw, "from"), extract(raw, "number"), extract(raw, "sender"))); + String texto = firstNonBlank(extract(raw, "conversation"), extract(raw, "text"), extract(raw, "body"), extract(raw, "message")); + if (numero.isBlank()) { + HttpResponses.json(ex, 400, Map.of("erro", "numero_nao_identificado")); + return; + } + try (Connection c = masterDs.getMasterConnection()) { + Map envio = one(c, "SELECT * FROM integracao_mensagens WHERE telefone=? AND callback_url IS NOT NULL AND status='enviado' ORDER BY criado_em DESC,id DESC LIMIT 1", numero); + if (envio == null) { + HttpResponses.json(ex, 200, Map.of("ok", true, "encaminhado", false, "motivo", "envio_nao_encontrado")); + return; + } + long respostaId = registrarResposta(c, ((Number)envio.get("id")).longValue(), raw, texto); + boolean encaminhado = encaminharCallback(envio, respostaId, numero, texto); + HttpResponses.json(ex, 200, Map.of("ok", true, "encaminhado", encaminhado, "mensagem_id", envio.get("id"), "resposta_id", respostaId)); + } + } + + private List resolverDestinatarios(long empresaId, String raw) throws Exception { + List out = new ArrayList<>(); + try (Connection t = tenantDs.getConnection(empresaId); Connection m = masterDs.getMasterConnection()) { + for (String token : raw.split(",")) { + String item = token.trim(); + if (item.isBlank()) continue; + String[] p = item.split(":", 2); + String tipo = p[0].trim().toLowerCase(Locale.ROOT); + Long id = p.length > 1 && !p[1].isBlank() ? Long.parseLong(p[1].trim()) : null; + if ("escritorio".equals(tipo) || "funcionarios".equals(tipo) || "funcionario".equals(tipo)) { + if (id == null) out.addAll(funcionarios(m, empresaId)); else addIfPresent(out, funcionario(m, empresaId, id)); + } else if ("extra".equals(tipo) || "extras".equals(tipo)) { + if (id == null) out.addAll(extras(t)); else addIfPresent(out, extra(t, id)); + } else if (Set.of("sindico", "subsindico", "conselheiro").contains(tipo)) { + out.addAll(contatosPorTipo(t, tipo)); + } else { + throw new IllegalArgumentException("Destinatario invalido: " + item); + } + } + } + Map unicos = new LinkedHashMap<>(); + for (Destinatario d : out) unicos.put(d.tipo()+":"+d.id()+":"+telefoneWhatsapp(d.telefone()), d); + return new ArrayList<>(unicos.values()); + } + + private ApiKeyConfig apiKeyConfig(String chave, long empresaId) throws SQLException { + if (chave == null || chave.isBlank()) return new ApiKeyConfig("externo", null, null); + try (Connection c = masterDs.getMasterConnection(); PreparedStatement ps = c.prepareStatement("SELECT descricao, callback_url, callback_secret FROM api_keys WHERE key_hash=? AND empresa_id=? AND ativa=true")) { + ps.setString(1, ApiKeyService.hash(chave)); + ps.setLong(2, empresaId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return new ApiKeyConfig("externo", null, null); + String sistema = rs.getString("descricao"); + return new ApiKeyConfig(sistema == null || sistema.isBlank() ? "externo" : sistema, rs.getString("callback_url"), rs.getString("callback_secret")); + } + } + } + + private boolean encaminharCallback(Map envio,long respostaId,String numero,String texto){ + String url=str(envio.get("callback_url")); + if(url.isBlank())return false; + try{ + validarCallbackUrl(url); + Map payload=new LinkedHashMap<>(); + payload.put("mensagem_id",envio.get("id")); + payload.put("resposta_id",respostaId); + payload.put("empresa_id",envio.get("empresa_id")); + payload.put("sistema_externo",envio.get("sistema_externo")); + payload.put("referencia_externa",envio.get("referencia_externa")); + payload.put("telefone",numero); + payload.put("resposta",texto); + String body=Json.write(payload); + HttpRequest.Builder req=HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15)).header("Content-Type","application/json"); + String secret=str(envio.get("callback_secret")); + if(!secret.isBlank())req.header("X-Seconectai-Signature", hmac(secret, body)); + HttpResponse resp=httpClient.send(req.POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + return resp.statusCode()>=200&&resp.statusCode()<300; + }catch(Exception e){System.err.println("Falha callback integracao: "+e.getMessage());return false;} + } + + private void validarWebhookEvolution(HttpExchange ex) { + String recebido = ex.getRequestHeaders().getFirst("X-Webhook-Secret"); + if (webhookSecret == null || webhookSecret.isBlank() || recebido == null || !MessageDigest.isEqual(webhookSecret.getBytes(StandardCharsets.UTF_8), recebido.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalArgumentException("webhook_nao_autorizado"); + } + } + + private void validarCallbackUrl(String value) throws Exception { + URI uri = URI.create(value); + if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("callback_url precisa usar https"); + String host = uri.getHost(); + if (host == null || host.isBlank()) throw new IllegalArgumentException("callback_url sem host"); + String lower = host.toLowerCase(Locale.ROOT); + if (Set.of("localhost", "127.0.0.1", "0.0.0.0", "::1").contains(lower) || lower.endsWith(".local")) throw new IllegalArgumentException("callback_url local nao permitido"); + for (InetAddress addr : InetAddress.getAllByName(host)) { + if (addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isMulticastAddress()) throw new IllegalArgumentException("callback_url em rede privada nao permitido"); + } + } + + private String hmac(String secret, String body) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "sha256=" + HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8))); + } + + private List funcionarios(Connection c, long emp) throws SQLException {return dests(c, "SELECT id,nome,telefone FROM funcionarios WHERE empresa_id=? AND status='ativo' ORDER BY nome", "funcionario", emp);} + private Destinatario funcionario(Connection c, long emp, long id) throws SQLException {return dest(c, "SELECT id,nome,telefone FROM funcionarios WHERE empresa_id=? AND id=? AND status='ativo'", "funcionario", emp, id);} + private List extras(Connection c) throws SQLException {return dests(c, "SELECT id,nome,telefone FROM destinatarios_extra WHERE status='ativo' ORDER BY nome", "extra");} + private Destinatario extra(Connection c, long id) throws SQLException {return dest(c, "SELECT id,nome,telefone FROM destinatarios_extra WHERE id=? AND status='ativo'", "extra", id);} + private List contatosPorTipo(Connection c, String tipo) throws SQLException {return dests(c, "SELECT c.id,c.nome,c.telefone FROM contatos c JOIN contato_condominio cc ON cc.contato_id=c.id WHERE cc.tipo=? AND cc.status='ativo' ORDER BY c.nome", tipo, tipo);} + private List dests(Connection c, String sql, String tipo, Object... params) throws SQLException {List out=new ArrayList<>();try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new Destinatario(tipo,rs.getLong("id"),rs.getString("nome"),rs.getString("telefone")));}}return out;} + private Destinatario dest(Connection c, String sql, String tipo, Object... params) throws SQLException {try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){return rs.next()?new Destinatario(tipo,rs.getLong("id"),rs.getString("nome"),rs.getString("telefone")):null;}}} + private void addIfPresent(List out, Destinatario d){if(d!=null)out.add(d);} + private long registrarMensagem(Connection c,long emp,ApiKeyConfig cfg,String ref,Destinatario d,String tel,String msg,String status,String detalhe)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO integracao_mensagens (empresa_id,sistema_externo,referencia_externa,callback_url,callback_secret,tipo_destinatario,destinatario_id,destinatario_nome,telefone,mensagem,status,evolution_response) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id")){ps.setLong(1,emp);ps.setString(2,cfg.sistemaExterno());ps.setString(3,ref);ps.setString(4,emptyToNull(cfg.callbackUrl()));ps.setString(5,emptyToNull(cfg.callbackSecret()));ps.setString(6,d.tipo());ps.setLong(7,d.id());ps.setString(8,d.nome());ps.setString(9,tel);ps.setString(10,msg);ps.setString(11,status);ps.setString(12,detalhe);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}} + private long registrarResposta(Connection c,long msgId,String raw,String texto)throws SQLException{try(PreparedStatement ps=c.prepareStatement("INSERT INTO integracao_respostas (integracao_mensagem_id,resposta_texto,payload) VALUES (?,?,?) RETURNING id")){ps.setLong(1,msgId);ps.setString(2,texto);ps.setString(3,raw);try(ResultSet rs=ps.executeQuery()){rs.next();return rs.getLong(1);}}} + private Map evolutionAprovada(long emp)throws SQLException{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT * FROM evolution_instancias WHERE empresa_id=? AND status='aprovada' ORDER BY aprovado_em DESC,id DESC LIMIT 1")){ps.setLong(1,emp);try(ResultSet rs=ps.executeQuery()){return rs.next()?map(rs):null;}}} + private Map one(Connection c,String sql,Object...params)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){bind(ps,params);try(ResultSet rs=ps.executeQuery()){return rs.next()?map(rs):null;}}} + private Map map(ResultSet rs)throws SQLException{Mapm=new LinkedHashMap<>();ResultSetMetaData md=rs.getMetaData();for(int i=1;i<=md.getColumnCount();i++)m.put(md.getColumnLabel(i),rs.getObject(i));return m;} + private void bind(PreparedStatement ps,Object...params)throws SQLException{for(int i=0;i m,String k){String v=m.get(k);if(v==null||v.isBlank())throw new IllegalArgumentException("Campo obrigatorio: "+k);return v;} + private String telefoneWhatsapp(String raw){String d=EvolutionApiClient.onlyDigits(raw);if((d.length()==10||d.length()==11)&&!d.startsWith("55"))return "55"+d;return d;} + private String str(Object v){return v==null?"":String.valueOf(v);} + private String emptyToNull(String v){return v==null||v.isBlank()?null:v;} + private String firstNonBlank(String... values){for(String v:values)if(v!=null&&!v.isBlank())return v;return "";} + private String extract(String raw,String key){String marker="\""+key+"\"";int p=raw.indexOf(marker);if(p<0)return null;int colon=raw.indexOf(':',p);if(colon<0)return null;int q=raw.indexOf('"',colon+1);int e=q<0?-1:raw.indexOf('"',q+1);if(q<0||e<0)return null;return raw.substring(q+1,e).split("@")[0];} + private record Destinatario(String tipo,long id,String nome,String telefone){} + private record ApiKeyConfig(String sistemaExterno,String callbackUrl,String callbackSecret){} +} diff --git a/src/main/resources/migrations/master/020_integracao_mensagens.sql b/src/main/resources/migrations/master/020_integracao_mensagens.sql new file mode 100644 index 0000000..4ffaa50 --- /dev/null +++ b/src/main/resources/migrations/master/020_integracao_mensagens.sql @@ -0,0 +1,32 @@ +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS callback_url TEXT; +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS callback_secret TEXT; + +CREATE TABLE IF NOT EXISTS integracao_mensagens ( + id BIGSERIAL PRIMARY KEY, + empresa_id BIGINT NOT NULL REFERENCES empresas(id), + sistema_externo VARCHAR(120) NOT NULL DEFAULT 'externo', + referencia_externa VARCHAR(160), + callback_url TEXT, + callback_secret TEXT, + tipo_destinatario VARCHAR(30) NOT NULL, + destinatario_id BIGINT NOT NULL, + destinatario_nome VARCHAR(160), + telefone VARCHAR(30) NOT NULL, + mensagem TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pendente', + evolution_response TEXT, + criado_em TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_integracao_mensagens_empresa ON integracao_mensagens(empresa_id, criado_em DESC); +CREATE INDEX IF NOT EXISTS idx_integracao_mensagens_telefone ON integracao_mensagens(telefone, criado_em DESC); + +CREATE TABLE IF NOT EXISTS integracao_respostas ( + id BIGSERIAL PRIMARY KEY, + integracao_mensagem_id BIGINT NOT NULL REFERENCES integracao_mensagens(id), + resposta_texto TEXT, + payload TEXT NOT NULL, + recebido_em TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_integracao_respostas_mensagem ON integracao_respostas(integracao_mensagem_id, recebido_em DESC);