Kaynağa Gözat

integração alfred/ixc

leonardo 1 ay önce
ebeveyn
işleme
82bc657fc8

+ 8 - 0
.env.example

@@ -113,3 +113,11 @@ ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
 # WhatsApp (Baileys — conexão via QR code, sem token/segredo nenhum). O estado da sessão
 # fica em backend/data/baileys-session/ (não versionado). Conectar/desconectar pelo painel
 # admin em /api/whatsapp (status, conectar, desconectar).
+
+# Integrador IXC (Alfred) — usadas só pelo seed (npm run seed:run), que provisiona a
+# linha na tabela "integracao". Em runtime o service lê Url/Login/Senha/Token do banco,
+# não daqui (token sobrevive a restart; ver src/functions/atualizaTokenIxcIntegradorERetorna.js).
+# Rodar o seed de novo com Login/Senha diferentes força um novo login (invalida o token cacheado).
+IXC_INTEGRADOR_BASE_URL=http://localhost:4001/
+IXC_INTEGRADOR_LOGIN=
+IXC_INTEGRADOR_SENHA=

+ 16 - 0
db/migrations/20260731120000_create_integracao_table.cjs

@@ -0,0 +1,16 @@
+exports.up = function (knex) {
+  return knex.schema.createTable("integracao", (t) => {
+    t.increments("Id");
+    t.string("Nome");
+    t.integer("Tipo");
+    t.string("Chave");
+    t.string("Url");
+    t.string("Token");
+    t.timestamp("Atualizacao").notNullable().defaultTo(knex.fn.now());
+    t.json("Outros");
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("integracao");
+};

+ 20 - 0
db/seeds/integracao.js

@@ -0,0 +1,20 @@
+import { config } from "../../src/config/index.js";
+
+const TIPO_IXC = 1;
+
+
+export async function seed(knex) {
+  const { baseUrl, login, senha } = config.ixcIntegradorSeed;
+  if (!baseUrl) return;
+
+  await knex("integracao")
+    .insert({
+      Id: TIPO_IXC,
+      Nome: "Integrador IXC (Alfred)",
+      Tipo: TIPO_IXC,
+      Url: baseUrl,
+      Outros: JSON.stringify({ Login: login, Senha: senha })
+    })
+    .onConflict("Id")
+    .merge(["Nome", "Tipo", "Url", "Outros"]);
+}

+ 6 - 0
src/config/index.js

@@ -107,6 +107,12 @@ export const config = {
     model: process.env.OLLAMA_ATENDENTE_MODEL ?? "star-atendente",
     historyLimit: Number(process.env.ATENDENTE_SUGESTAO_HISTORY_LIMIT ?? 20),
     maxChars: Number(process.env.ATENDENTE_SUGESTAO_MAX_CHARS ?? 8000)
+  },
+ 
+  ixcIntegradorSeed: {
+    baseUrl: process.env.IXC_INTEGRADOR_BASE_URL ?? "",
+    login: process.env.IXC_INTEGRADOR_LOGIN ?? "",
+    senha: process.env.IXC_INTEGRADOR_SENHA ?? ""
   }
 };
 

+ 112 - 0
src/controllers/IxcIntegrador.Controller.js

@@ -0,0 +1,112 @@
+import {
+  buscarClientes,
+  buscarClienteCompleto,
+  buscarClienteBase,
+  buscarContratosCliente,
+  possuiContratoTelefonia,
+  buscarDetalhesContrato,
+  buscarOsAvancado,
+  buscarOsPorId,
+  buscarAtendimentoOsPorId,
+  buscarAssuntosOs
+} from "../services/ixcIntegradorService.js";
+import { NotFoundError } from "../shared/errors/index.js";
+
+export const IxcIntegradorController = {
+  BuscarClientes: async function (req, res, next) {
+    try {
+      const clientes = await buscarClientes(req.body.busca);
+      res.json({ clientes });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ObterCliente: async function (req, res, next) {
+    try {
+      const cliente = await buscarClienteCompleto(req.params.id);
+      if (!cliente) throw new NotFoundError("cliente_not_found");
+      res.json({ cliente });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ObterClienteBase: async function (req, res, next) {
+    try {
+      const cliente = await buscarClienteBase(req.params.id);
+      if (!cliente) throw new NotFoundError("cliente_not_found");
+      res.json({ cliente });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ListarContratosCliente: async function (req, res, next) {
+    try {
+      const contratos = await buscarContratosCliente(req.params.id);
+      res.json({ contratos });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  PossuiContratoTelefonia: async function (req, res, next) {
+    try {
+      const possui = await possuiContratoTelefonia(req.params.id);
+      res.json({ possui });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ObterDetalhesContrato: async function (req, res, next) {
+    try {
+      const contrato = await buscarDetalhesContrato(req.params.id, req.query.clienteId);
+      if (!contrato) throw new NotFoundError("contrato_not_found");
+      res.json({ contrato });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  BuscarOsAvancado: async function (req, res, next) {
+    try {
+      const result = await buscarOsAvancado(req.body);
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ObterOs: async function (req, res, next) {
+    try {
+      const os = await buscarOsPorId(req.params.id);
+      if (!os) throw new NotFoundError("os_not_found");
+      res.json({ os });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ObterAtendimentoOs: async function (req, res, next) {
+    try {
+      const os = await buscarAtendimentoOsPorId(req.params.id);
+      if (!os) throw new NotFoundError("os_not_found");
+      res.json({ os });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ListarAssuntosOs: async function (req, res, next) {
+    try {
+      const assuntos = await buscarAssuntosOs();
+      res.json({ assuntos });
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default IxcIntegradorController;

+ 62 - 0
src/functions/atualizaTokenIxcIntegradorERetorna.js

@@ -0,0 +1,62 @@
+import { Integracao, INTEGRACAO_TIPO_IXC } from "../models/Integracao.model.js";
+
+function parseOutros(outros) {
+  if (!outros) return {};
+  return typeof outros === "string" ? JSON.parse(outros) : outros;
+}
+
+function baseUrlComBarra(url) {
+  return url.endsWith("/") ? url : `${url}/`;
+}
+
+export async function atualizaTokenIxcIntegradorERetorna() {
+  const integracao = await Integracao.query().findOne({ Tipo: INTEGRACAO_TIPO_IXC });
+  if (!integracao) {
+    const err = new Error("integracao_ixc_nao_configurada");
+    err.statusCode = 502;
+    throw err;
+  }
+
+  const baseUrl = baseUrlComBarra(integracao.Url);
+  const outros = parseOutros(integracao.Outros);
+
+  if (outros.token) {
+    try {
+      const statusRes = await fetch(`${baseUrl}usuario/status`, {
+        method: "POST",
+        headers: { Authorization: `Bearer ${outros.token}` },
+        signal: AbortSignal.timeout(10_000)
+      });
+      if (statusRes.ok) {
+        const statusJson = await statusRes.json().catch(() => null);
+        if (statusJson?.ok) return integracao;
+      }
+    } catch {
+  
+    }
+  }
+
+  const loginRes = await fetch(`${baseUrl}usuario/login`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ Login: outros.Login, Senha: outros.Senha }),
+    signal: AbortSignal.timeout(10_000)
+  });
+
+  if (!loginRes.ok) {
+    const err = new Error(`ixc_integrador_login_falhou:${loginRes.status}`);
+    err.statusCode = 502;
+    throw err;
+  }
+
+  const loginJson = await loginRes.json().catch(() => null);
+  if (!loginJson?.token) {
+    const err = new Error("ixc_integrador_login_sem_token");
+    err.statusCode = 502;
+    throw err;
+  }
+
+  return integracao.$query().patchAndFetch({
+    Outros: JSON.stringify({ Login: outros.Login, Senha: outros.Senha, token: loginJson.token })
+  });
+}

+ 41 - 0
src/middleware/schemas/IxcIntegrador.Schema.js

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+export const ixcClienteBuscaSchema = z.object({
+  busca: z.string().min(1).max(200)
+});
+
+export const ixcContratoDetalhesQuerySchema = z.object({
+  clienteId: z.coerce.number().int().positive()
+});
+
+
+const ORDER_BY_PERMITIDOS = [
+  "data_abertura",
+  "data_agenda",
+  "data_fechamento",
+  "protocolo",
+  "status",
+  "id_cliente",
+  "id_tecnico",
+  "id_cidade",
+  "id"
+];
+
+export const ixcOsBuscaAvancadaSchema = z.object({
+  inicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+  fim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+  dataAgendamento: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+  assunto: z.array(z.number().int().positive()).optional(),
+  status: z.array(z.string()).optional(),
+  id_cliente: z.number().int().positive().optional(),
+  nome_cliente: z.string().min(1).max(200).optional(),
+  id_tecnico: z.number().int().positive().optional(),
+  protocolo: z.string().min(1).max(100).optional(),
+  id_cidade: z.number().int().positive().optional(),
+  setor: z.string().min(1).max(100).optional(),
+  page: z.number().int().positive().optional(),
+  limit: z.number().int().positive().max(1000).optional(),
+  orderBy: z.enum(ORDER_BY_PERMITIDOS).optional(),
+  order: z.enum(["ASC", "DESC", "asc", "desc"]).optional(),
+  incluirMensagens: z.boolean().optional()
+});

+ 17 - 0
src/models/Integracao.model.js

@@ -0,0 +1,17 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+
+
+export const INTEGRACAO_TIPO_IXC = 1;
+
+export class Integracao extends Model {
+  static get tableName() {
+    return "integracao";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+}
+
+export default Integracao;

+ 31 - 0
src/routes/IxcIntegrador.Rotas.js

@@ -0,0 +1,31 @@
+import { Router } from "express";
+import { IxcIntegradorController } from "../controllers/IxcIntegrador.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+import { requireAdmin } from "../middleware/RequireAdmin.js";
+import { parseId } from "../middleware/ParseId.js";
+import { validate } from "../middleware/Validate.js";
+import {
+  ixcClienteBuscaSchema,
+  ixcContratoDetalhesQuerySchema,
+  ixcOsBuscaAvancadaSchema
+} from "../middleware/schemas/IxcIntegrador.Schema.js";
+
+export const ixcIntegradorRouter = Router();
+
+
+ixcIntegradorRouter.use(requireUser, requireAdmin);
+
+ixcIntegradorRouter.post("/clientes/buscar", validate({ body: ixcClienteBuscaSchema }), IxcIntegradorController.BuscarClientes);
+ixcIntegradorRouter.get("/clientes/:id", parseId, IxcIntegradorController.ObterCliente);
+ixcIntegradorRouter.get("/clientes/:id/base", parseId, IxcIntegradorController.ObterClienteBase);
+ixcIntegradorRouter.get("/clientes/:id/contratos", parseId, IxcIntegradorController.ListarContratosCliente);
+ixcIntegradorRouter.get("/clientes/:id/possui-contrato-telefonia", parseId, IxcIntegradorController.PossuiContratoTelefonia);
+
+ixcIntegradorRouter.get("/contratos/:id", parseId, validate({ query: ixcContratoDetalhesQuerySchema }), IxcIntegradorController.ObterDetalhesContrato);
+
+ixcIntegradorRouter.post("/os/buscar", validate({ body: ixcOsBuscaAvancadaSchema }), IxcIntegradorController.BuscarOsAvancado);
+ixcIntegradorRouter.get("/os/assuntos", IxcIntegradorController.ListarAssuntosOs);
+ixcIntegradorRouter.get("/os/atendimento/:id", parseId, IxcIntegradorController.ObterAtendimentoOs);
+ixcIntegradorRouter.get("/os/:id", parseId, IxcIntegradorController.ObterOs);
+
+export default ixcIntegradorRouter;

+ 2 - 0
src/routes/index.js

@@ -11,6 +11,7 @@ import { conversationsRouter } from "./Conversations.Rotas.js";
 import { atendimentosRouter } from "./Atendimentos.Rotas.js";
 import { conversasRouter } from "./Conversas.Rotas.js";
 import { whatsappConexaoRouter } from "./WhatsappConexao.Rotas.js";
+import { ixcIntegradorRouter } from "./IxcIntegrador.Rotas.js";
 
 export class Roteamento {
   static IniciarRoteamento(app) {
@@ -26,6 +27,7 @@ export class Roteamento {
     api.use("/atendimentos", atendimentosRouter);
     api.use("/conversas", conversasRouter);
     api.use("/whatsapp", whatsappConexaoRouter);
+    api.use("/ixc", ixcIntegradorRouter);
 
     app.use("/api", rateLimitMiddleware, authMiddleware, api);
   }

+ 87 - 0
src/services/ixcIntegradorService.js

@@ -0,0 +1,87 @@
+import { atualizaTokenIxcIntegradorERetorna } from "../functions/atualizaTokenIxcIntegradorERetorna.js";
+
+function parseOutros(outros) {
+  if (!outros) return {};
+  return typeof outros === "string" ? JSON.parse(outros) : outros;
+}
+
+function baseUrlComBarra(url) {
+  return url.endsWith("/") ? url : `${url}/`;
+}
+
+
+async function fetchJson(path, { method = "GET", body } = {}) {
+  const integracao = await atualizaTokenIxcIntegradorERetorna();
+  const { token } = parseOutros(integracao.Outros);
+  const baseUrl = baseUrlComBarra(integracao.Url);
+
+  const res = await fetch(`${baseUrl}${path}`, {
+    method,
+    headers: {
+      Authorization: `Bearer ${token}`,
+      ...(body ? { "Content-Type": "application/json" } : {})
+    },
+    body: body ? JSON.stringify(body) : undefined,
+    signal: AbortSignal.timeout(15_000)
+  });
+
+  if (!res.ok) {
+    const err = new Error(`ixc_integrador_fetch_error:${res.status}`);
+    err.statusCode = 502;
+    throw err;
+  }
+
+  try {
+    return await res.json();
+  } catch {
+    const err = new Error("ixc_integrador_invalid_json");
+    err.statusCode = 502;
+    throw err;
+  }
+}
+
+export async function buscarClientes(busca) {
+  const json = await fetchJson("cliente/", { method: "POST", body: { busca } });
+  return json.clientes ?? [];
+}
+
+export async function buscarClienteCompleto(idClienteIxc) {
+  const json = await fetchJson(`cliente/dados/${idClienteIxc}`, { method: "POST" });
+  return json.cliente ?? null;
+}
+
+export async function buscarClienteBase(idClienteIxc) {
+  const json = await fetchJson(`cliente/dados/${idClienteIxc}/base`, { method: "POST" });
+  return json.cliente ?? null;
+}
+
+export async function buscarContratosCliente(idClienteIxc) {
+  return fetchJson(`contrato/${idClienteIxc}`);
+}
+
+export async function possuiContratoTelefonia(idClienteIxc) {
+  return fetchJson(`contrato/possui-contrato-telefonia/${idClienteIxc}`);
+}
+
+export async function buscarDetalhesContrato(idContratoIxc, idClienteIxc) {
+  return fetchJson(`contrato/detalhes/${idContratoIxc}?cliente_id=${idClienteIxc}`);
+}
+
+export async function buscarOsAvancado(filtros) {
+  return fetchJson("os/busca-avancada", { method: "POST", body: filtros });
+}
+
+export async function buscarOsPorId(id) {
+  const json = await fetchJson(`os/${id}`);
+  return json.os ?? null;
+}
+
+export async function buscarAtendimentoOsPorId(id) {
+  const json = await fetchJson(`os/atendimento/${id}`);
+  return json.os ?? null;
+}
+
+export async function buscarAssuntosOs() {
+  const json = await fetchJson("os/assuntos");
+  return json.assuntos ?? [];
+}