Compare commits

...

45 Commits
v1.0 ... master

Author SHA1 Message Date
be6d4a891e Merge pull request 'FIX: Logger escondia contexto do erro atras do stack trace' (#7) from dev into master
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 43s
Reviewed-on: #7
2026-07-20 17:00:57 -03:00
3b050e626c FIX: Logger escondia contexto do erro atras do stack trace
winston.format.printf usava "stack || message", entao qualquer erro
logado com stack trace (todo uso de logError) perdia a mensagem de
contexto (ex: "Falha ao buscar status... sysId: X") e so mostrava o
stack puro, sem dizer qual ticket/operacao falhou. Agora mostra os
dois: mensagem de contexto primeiro, stack trace logo abaixo.
2026-07-20 16:59:17 -03:00
bc0fc282a3 CI: Deploy roda como desenvolvimento ao inves de root
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 43s
Reviewed-on: #6
2026-07-20 15:42:05 -03:00
0df7443379 CI: Deploy roda como desenvolvimento ao inves de root
O runner (gitea-runner) roda como root e é compartilhado com outras
automações deste servidor, então não dá pra trocar o usuário dele
globalmente. O rsync que lê o checkout continua como root (é a única
forma de ler, o workspace do runner é privado dele), mas logo depois
do sync a posse dos arquivos passa pro 'desenvolvimento', e dali em
diante (npm ci, node --check, pm2 startOrRestart/save/list) tudo roda
via sudo -u desenvolvimento - resolve o processo órfão de root que
duplicava o cron ao lado do PM2 do desenvolvimento.
2026-07-20 15:39:23 -03:00
e67d376c4f FIX: Alterado criador da pasta previous para o root
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 36s
2026-07-20 15:34:56 -03:00
f934b8f4ae FIX: Alterado criador da pasta previous para o root 2026-07-20 15:33:12 -03:00
64e384036a Merge pull request 'FIX: BugFix relacionado ao status de requisições' (#4) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 9s
Reviewed-on: #4
2026-07-20 15:26:33 -03:00
dd1d1e0851 CI: Definido para que automação rode como user desenvolvimento ao invés de root 2026-07-20 15:23:28 -03:00
8e4e79d059 FIX: Corrigido status em espera para requisições
- Requisições possui um código diferente de incidentes para em espera
- Adicionado if tipo === 'reuqusicao' em espera = -5
2026-07-20 15:22:37 -03:00
b16a420fab Merge pull request 'FIX: Secret ENV_PRODUCTION colado direto no script bash quebrava com $ dentro do conteudo' (#3) from dev into master
All checks were successful
Deploy production / Deploy to production VM (push) Successful in 45s
Reviewed-on: #3
2026-07-15 14:02:26 -03:00
e838081aae FIX: Secret ENV_PRODUCTION colado direto no script bash quebrava com $ dentro do conteudo
${{ secrets.ENV_PRODUCTION }} usado direto dentro de um run: substitui o texto
antes do bash rodar - qualquer '$algo' dentro do conteudo do .env.production
(por exemplo em senhas) era interpretado como expansao de variavel de shell.
Com set -u ligado isso quebrou o step "Check required secrets" (variavel nao
definida = erro fatal). Sem set -u, o step "Write .env.production from
secret" teria o mesmo problema de forma silenciosa: qualquer '$algo' dentro
das credenciais reais seria substituido por vazio no arquivo escrito na VM,
corrompendo senha sem nenhum aviso.

Corrige passando o secret via env: e referenciando como variavel de ambiente
normal ($ENV_PRODUCTION) nos dois steps - bash trata o valor como dado opaco,
sem tentar expandir nada dentro dele.
2026-07-15 14:01:49 -03:00
0daefc243c Merge pull request 'FIX: Preflight falhava por causa de pipefail com rsync --version | head -n 1' (#2) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 5s
Reviewed-on: #2
2026-07-15 13:59:39 -03:00
cefeb3ea25 FIX: Preflight falhava por causa de pipefail com rsync --version | head -n 1
O head fecha o pipe apos ler a primeira linha, o rsync recebe broken pipe e sai
com codigo de erro, e o pipefail propaga isso como falha do step inteiro -
mesmo a versao ja tendo sido impressa com sucesso. Confirmado no primeiro run
real do workflow em producao: Preflight falhou logo depois de imprimir as
versoes, todos os steps seguintes (secrets, validacao, deploy) ficaram
pulados, sem tocar na VM.
2026-07-15 13:58:36 -03:00
5b30849f79 Merge pull request 'Motor de regras único, mandante configurável e deploy via secret do Gitea' (#1) from dev into master
Some checks failed
Deploy production / Deploy to production VM (push) Failing after 16s
Reviewed-on: #1
2026-07-15 13:54:57 -03:00
2cf6e9f2fb CI: Materializa .env.production via secret, backup pra rollback e node --check dinamico
Adiciona um step que escreve o .env.production a partir do secret ENV_PRODUCTION
do Gitea Actions (com chmod 600), eliminando o modelo anterior de manter esse
arquivo manualmente na VM sem nenhuma copia de seguranca. Um step anterior falha
cedo e com mensagem clara se o secret nao estiver configurado.

Antes de sincronizar o codigo novo, guarda a versao anterior em
$DEPLOY_PATH.previous, permitindo rollback manual rapido caso o deploy suba
algo quebrado (o node --check so pega erro de sintaxe, nao de logica).

Troca a lista fixa de arquivos validados por node --check (que ja estava
desatualizada, sem cobrir arquivos tocados em mudancas recentes) por uma
varredura em todo .js do projeto.
2026-07-15 13:31:35 -03:00
2437864133 DOCS: Manual do usuario + documentacao de onde fica o .env.production e rollback
Cria docs/manualusuario.md: guia pratico "se eu fizer X no SN/GLPI, acontece Y",
pra quem usa os sistemas no dia a dia sem precisar entender a implementacao.

README ganha duas secoes na parte de Producao: onde o .env.production fica e
por que (agora materializado a partir do secret ENV_PRODUCTION do Gitea, nao
mais um arquivo mantido a mao na VM), e o passo a passo de rollback manual
usando o backup .previous que o deploy passa a manter.
2026-07-15 13:31:07 -03:00
cc17d957c7 PERF: Reduz verbosidade de logs em nivel info no ciclo do cron
Rebaixa para debug uma serie de logs "heartbeat" que disparavam todo ciclo
(a cada 1 minuto) independente de qualquer mudanca real, competindo com os
logs de acao/erro que realmente importam: buscas por watermark, contagem
de tickets pendentes/monitorados, checagens de comentario ja sincronizado,
abertura de conexao GLPI/PostgreSQL, e o resumo de sincronizacao do SN
(que a margem de seguranca de 6h do watermark sempre retorna nao-vazio,
mesmo sem mudanca real de campo).

Corrige tambem um bug real encontrado em teste: no fluxo GLPI -> SN, todo
comentario historico de um ticket era sanitizado e checado contra a API do
SN a cada ciclo antes mesmo de verificar se ja tinha sido sincronizado
(ticket_updates.destiny_id). Em tickets com historico longo isso
significava dezenas de chamadas desnecessarias por minuto. Agora a
checagem local (barata) roda primeiro, e so sanitiza/consulta o SN quando
o comentario realmente ainda nao foi sincronizado.

Corrige tambem o log de configuracao do pool PostgreSQL, que imprimia
"[object Object]" por passar o objeto de config como mensagem em vez de
metadado.

LOG_LEVEL default muda de debug para info em .env.example e
.env.production, unica forma do rebaixamento acima ter efeito pratico.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:00:29 -03:00
bce9a52494 FEAT: Motor de regras único de status/fechamento + mandante configurável
Consolida os dois controladores legados que decidiam status/fechamento de
forma concorrente (processGlpiClosureController + processStatusController)
em um único processTicketLifecycleController, eliminando a dependência do
campo source_last (mantido só como rastro de auditoria) e a flag
ENABLE_STATUS_SYNC (removida). Isso corrige o bug de reabertura: chamados
reabertos no SN voltavam a ser fechados no GLPI porque a regra de
precedência de finalização do fluxo legado forçava o status do GLPI de
volta pro SN.

Correções de bugs encontrados em teste (Frente 1):
- Constraint UNIQUE em ticket_sync.sn_ticket_id + ON CONFLICT DO NOTHING
  na criação do registro, prevenindo duplicação de tickets.
- Fechamento no SN agora é confirmado por leitura ao vivo do status antes
  de marcar o ticket como solved/closed, em vez de confiar só no HTTP 200
  (a API do SN pode retornar sucesso e silenciosamente ignorar um valor de
  estado inválido — descoberto e corrigido também no mandante).
- Nota de encerramento do SN pro GLPI agora é idempotente e criada uma
  única vez por ticket.
- Erros de sincronização passam a ter um limite de tentativas
  (MAX_ERROR_RETRIES) antes de marcar o ticket como error_permanent,
  exigindo intervenção manual em vez de tentar para sempre.
- Chamados criados no GLPI passam a gravar o número do ticket do SN no
  campo externalid.

Nova funcionalidade (Frente 2): quando o SN resolve/encerra um chamado por
conta própria antes do GLPI, além da nota informativa no GLPI (agora com o
nome de quem encerrou, buscado ao vivo via resolved_by/closed_by/
sys_updated_by), o próprio SN recebe um aviso de que a Sothis não vai mais
atender aquele chamado e ele é transferido pra fila de TI da CAOA.

Nova funcionalidade (Mandante, regras 19-22): espelhamento opcional e
configurável de status intermediário (Aguardando Atendimento/Em
Atendimento/Em Espera) entre os dois sistemas, controlado pela env
MANDANTE=GLPI|SNOW. Sem essa env configurada, mantém o comportamento atual
(nenhum espelhamento de status intermediário). Nunca afeta os status finais
(resolvido/encerrado/fora de escopo), que continuam regidos pelas regras
fixas de fechamento.

Corrigido também o workflow de deploy (.gitea/workflows/deploy-production.yml),
que ainda validava os controladores antigos (já removidos) e exigia
ENABLE_STATUS_SYNC=true, o que quebraria o próximo deploy em produção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:45:34 -03:00
ed19cd76a7 FEAT: RN atualizada. Chamados que estão fechados fora do escopo saem da fila sothis e entram na fila CAOA no ServiceNow 2026-06-26 11:39:57 -03:00
00b573d60d HOTFIX: Correção do Bug Em PROD
- Travas foram adicionadas para que a reabertura nao seja feita
- Regra de desatribuição de usuario caso seja "Fora de Escopo"
2026-03-11 17:02:27 -03:00
686c2f0a54 HOTFIX: Reordenação de regras de negócio
- Prioriza encerramento
- Secundariamente a reabertura
2026-03-11 10:44:09 -03:00
Gabriel Amancio
b4a7e6c366 HOTFIX: Alterado condição de status para poder sincronizar como SNOW. 2026-03-11 08:21:38 -03:00
Rafael Lopes
b2cbeb66e5 Merge branch 'feature/snow-worknotes-glpi-task-review' 2026-03-10 13:22:18 -03:00
Rafael Lopes
50d5b77261 HOTFIX: Corrgidigo bug em prod 2026-03-10 12:53:33 -03:00
6b67d5002f FIX: Correção na lógica de detecção de reabertura de ticket
- Adicionada lógica para detectar quando um ticket é reaberto, verificando se o status do ticket mudou de "Fechado" para "Aberto".
- Isso garante que os comentários sejam processados corretamente mesmo em casos de reabertura de tickets.
2026-03-09 16:38:06 -03:00
c964bcf18a FEATURE: Chamados reabertos possuem uma nova coloração 2026-03-04 18:15:53 -03:00
ce294d4220 FIX: Mensagem de fechamento de chamado atualizado para versão menos agressiva 2026-03-04 17:33:57 -03:00
dfabf699a5 DOC: Ajuste final para release em PROD 2026-03-04 16:48:23 -03:00
35b4a1fef9 FIX/FEATURE: Ajustes de fluxo SN<->GLPI e documentação operacional
- feito ajuste do fluxo GLPI->SN para tratar status 5 (resolve no SN) e status 6 (aviso definitivo e retirada de monitoramento)
- feito ajuste de reabertura SN->GLPI quando ticket estiver solved no GLPI
- feito ajuste de data/hora de comments/worknotes para evitar inversão de data
- feito ajuste de vínculo Ticket Externo no SN com validação de persistência e retry idempotente
- feito ajuste de filtro de journals para ignorar SOTHIS.CAOA e admin.caoa por configuração
- feito inclusão de monitor dedicado de encerramento definitivo (processGlpiClosureController)
- feito atualização de flags de controle (ENABLE_STATUS_SYNC, ENABLE_GLPI_CLOSE_CRON, ENABLE_GLPI_WEBHOOK)
- feito atualização da documentação (README, docs/regrasdenegocio.md, docs/fluxo.md, docs/casosdeuso.md, docs/checklist.md)
2026-03-04 16:37:04 -03:00
a7f9f7f714 FIX : Evitar sobrescrição 2026-03-04 10:05:30 -03:00
0c6648b0b6 FIX: Ajusta exibicao de logs no PM2 em producao 2026-02-27 17:03:17 -03:00
Rafael Lopes
e129571812 FIX: Correções de sincronização SN<->GLPI
- feito ajuste de direção de status para respeitar source_last e evitar reabertura indevida

- feito tratamento de reabertura SN->GLPI após resolução, mantendo bloqueio para fechamento permanente

- feito ajuste de finalização GLPI->SN para propagar resolução/fechamento corretamente

- feito correção de runtime no processStatusController (uso de variável antes da inicialização)

- feito correção para não prender ticket em status transitório de comentários

- feito preservação de ordem cronológica e datas reais de comments/work_notes SN no GLPI

- feito normalização de autor (ex.: email/login para nome amigável)

- feito destaque visual para mensagens automáticas e encerramento no GLPI

- feito sanitização de justificativa na regra de fora de escopo

- feito padronização de variáveis de ambiente e remoção de inconsistências de config

- feito separação de SQL destrutivo para arquivo de manutenção dedicado
2026-02-24 17:42:33 -03:00
0f412a1588 FIX: Corrigido bug que impedia a alteração de status 2026-02-23 17:08:19 -03:00
c21c00075c feat(sync): mapear work_notes SN para tarefas GLPI e ajustar regras/README
- coleta comments e work_notes do SN\n- envia comments como followup e work_notes como tarefa no GLPI\n- usa sys_created_by como author no banco intermediario\n- formata mensagem SN->GLPI com autor no cabecalho\n- aplica regras de resolucao/encerramento SN sem fechar GLPI\n- documenta regras de negocio no README
2026-02-23 16:45:46 -03:00
eda8f558fd FEATURE/FIX: Ajusta encerramento SN->GLPI e direção do bastão da verdade
- Adiciona comentário estilizado no GLPI quando SN marca Resolvido, Encerrado ou Encerrado - Omitido
- Marca Sync como "closed" para remover o chamado da fila de sincronização
- Corrige a direção do bastão da verdade para preservar SNOW -> GLPI quando SN foi a última origem
2026-02-23 11:39:09 -03:00
a4f6797c8f FEATURE: Novo Cammpo adicionado!
- Campo de Justificativa adicionado para as requisições
2026-02-19 18:22:38 -03:00
e84647a2ba FIX : corrige bugs silenciosos
- normaliza comentarios e logs (ASCII)
- corrige campo no createSyncRecord (glpi_ticket_id)
- remove metodo duplicado updateSourceLast
- corrige uso de ID errado na atualizacao de status/comentarios
- atualiza o README
2026-02-19 11:21:40 -03:00
Desenvolvimento
019837acab CHORE: Unificando versoes 2026-02-19 11:05:52 -03:00
39231006dc CHORE: Direório de Logs adicionado ao .gitignore 2025-11-19 16:30:50 -03:00
25458d5e44 REFACTOR/BUG: Correção de bugs e alteção de run
- Alterado Código para que use o node cron e o gerenciamento seja feito pelo pm2
- Bug que não envia uma atualização para o SN quando um chamo é fechado fora do escopo corrigido
2025-11-19 16:01:37 -03:00
fb3f4eee75 RAW: Usuario alterado para Agente CAOA 2025-10-30 08:15:43 -03:00
ee168f5d5a FEAT: Python permanece rodando em loop sem a necessidade de um cron job 2025-10-30 07:04:21 -03:00
ae303e58fe CLEANUP: Comentarios atualizados para servicenowService.js 2025-10-29 14:40:21 -03:00
bfa0e8acc8 CLEANUP: Comentarios atualizados e Readme.md alimentado com V1.0 da documentação 2025-10-29 14:20:21 -03:00
0c4538355a FEAT: Chamados com status "Aguardnado Atendimento" e "Em Espera" são atualizados para "Em Atendimento" para que possam ser encerrados. 2025-10-28 17:57:39 -03:00
44 changed files with 3400 additions and 1421 deletions

43
.env
View File

@ -1,43 +0,0 @@
# Contents of the file: /servicenow-glpi-sync/servicenow-glpi-sync/.env.example
# This is an example of the environment variables needed for the application.
# ServiceNow API Configuration
SERVICENOW_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident?sysparm_display_value=all&sysparm_limit=20&sysparm_query=ORDERBYDESCsys_created_on&sysparm_fields=number%2state%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cvariables.caller_id%2Cvariables.location%2Cvariables.telephone_favorecido_rh%2Cvariables.numero_ramal%2Copened_by.email%2Csys_id%5Eassignment_group%3D1132c9e01bfad410e77eb9121b4bcbab"
SERVICENOW_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item?sysparm_fields=number%2Cstate%2Crequest%2Copened_by%2Copened_at%2Clocation%2Cshort_description%2Cdescription%2Cassignment_group%2Csys_id&sysparm_display_value=all&sysparm_query=assignment_group%3D1132c9e01bfad410e77eb9121b4bcbab"
SERVICENOW_TABLE_INCIDENT_URL="https://caoadev.service-now.com/api/now/table/incident"
SERVICENOW_TABLE_REQUEST_URL="https://caoadev.service-now.com/api/now/table/sc_req_item"
SERVICENOW_TABLE_JOURNAL_URL="https://caoadev.service-now.com/api/now/table/sys_journal_field"
SERVICENOW_SC_ITEM_OPTION_URL="https://caoadev.service-now.com/api/now/table/sc_item_option_mtom"
SERVICENOW_USERNAME=SOTHIS.CAOA
SERVICENOW_PASSWORD=#Sothis20
# GLPI access via DB (use DB connection instead of API)
GLPI_DB_TYPE=mysql # ou postgresql
GLPI_DB_HOST=177.73.177.32
GLPI_DB_PORT=3306
GLPI_DB_USER=snglpi
GLPI_DB_PASSWORD=j2633669
GLPI_DB_NAME=glpi_data
GLPI_DB_CHARSET=utf8mb4
# Banco SNGLPI (PostgreSQL)
SNGLPI_DB_HOST=10.0.120.75
SNGLPI_DB_PORT=5432
SNGLPI_DB_NAME=snglpi
SNGLPI_DB_USER=desenvolvimento
SNGLPI_DB_PASSWORD=Ut@2S@$M9Xs@@W
# Caminho do CSV
LOCATION_MAPPING_CSV_PATH=\\10.0.121.40\Tecnica\Controle CAOA\ENTIDADE SERVICE NOW\LOCATION_MAPPING.csv
# Logging Configuration
LOG_LEVEL=info
LOG_FILE_PATH=logs/app.log
ERROR_LOG_FILE_PATH=logs/error.log
# Other Configuration
PORT=3000
NODE_ENV=development

85
.env.example Normal file
View File

@ -0,0 +1,85 @@
# ===================================================================
# EXEMPLO DE ARQUIVO DE CONFIGURACAO DE AMBIENTE
#
# Copie este arquivo para .env.development e .env.production
# e preencha com os valores corretos para cada ambiente.
# Nao adicione senhas ou chaves secretas diretamente neste arquivo.
# ===================================================================
# --- Configuracao Geral da Aplicacao ---
NODE_ENV=development
PORT=3000
# Frequencia de execucao do cron job (formato cron). Padrao: '* * * * *' (a cada 1 minutos)
CRON_SCHEDULE='* * * * *'
ENABLE_GLPI_WEBHOOK=true
ENABLE_GLPI_CLOSE_CRON=true
# Mandante (regras 19-22): sistema cujo status intermediario (Aguardando Atendimento/Em
# Atendimento/Em Espera) e espelhado automaticamente no outro sistema. Nunca afeta status
# finais (Resolvido/Encerrado/fora de escopo), so esses tres intermediarios.
# Valores: GLPI | SNOW | vazio (nenhum espelhamento, comportamento padrao)
MANDANTE=
# Numero maximo de vezes que um ticket em estado de erro e reprocessado
# automaticamente antes de ser marcado como 'error_permanent' (exige intervencao manual).
MAX_ERROR_RETRIES=5
# --- Configuracao da API do ServiceNow ---
# URL base da sua instancia do ServiceNow
SERVICENOW_INSTANCE=https://your-instance.service-now.com
SERVICENOW_USERNAME=
SERVICENOW_PASSWORD=
# Sys_id do grupo de atribuicao no ServiceNow que sera monitorado para novos tickets
SERVICENOW_ASSIGNMENT_GROUP=
# Sys_id do usuario padrao no ServiceNow que sera usado pela integracao para criar/atualizar tickets
SERVICENOW_DEFAULT_USER_SYSID=
SERVICENOW_DEFAULT_USER=
SERVICENOW_RESOLVED_BY_SYSID=
SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL=true
SERVICENOW_IGNORE_USERS_JOURNAL=SOTHIS.CAOA,admin.caoa
# Endpoints de tabela usados pela aplicacao
SERVICENOW_TABLE_INCIDENT_URL=
SERVICENOW_TABLE_REQUEST_URL=
SERVICENOW_TABLE_JOURNAL_URL=
SERVICENOW_SC_ITEM_OPTION_URL=
SERVICENOW_EXTERNAL_TICKET_FIELD=u_external_tickets
# Sys_id do grupo de TI da CAOA (destino de tickets fora do escopo Sothis)
SERVICENOW_CAOA_TI_GROUP_ID=
# --- Configuracao do Banco de Dados do GLPI (MySQL) ---
GLPI_DB_TYPE=mysql
GLPI_DB_HOST=localhost
GLPI_DB_PORT=3306
GLPI_DB_USER=
GLPI_DB_PASSWORD=
GLPI_DB_NAME=glpi
GLPI_DB_CHARSET=utf8mb4
# ID do usuario padrao do GLPI para criacao de tickets e comentarios
GLPI_DEFAULT_USER_ID=1118
GLPI_DEFAULT_GROUP_ID=30
GLPI_DEFAULT_ENTITY_ID=127
GLPI_OOS_SOLUTION_TYPE_ID=27
GLPI_REQ_DEFAULT_JUSTIFICATIVA=Chamado aberto por colaborador CAOA sem Justificativa
GLPI_REQ_DEFAULT_DESCRIPTION=Chamado aberto por colaborador CAOA sem Descricao
# --- Configuracao do Banco de Dados Intermediario (PostgreSQL) ---
SNGLPI_DB_HOST=localhost
SNGLPI_DB_PORT=5432
SNGLPI_DB_NAME=snglpi_sync
SNGLPI_DB_USER=
SNGLPI_DB_PASSWORD=
# --- Mapeamento de Localidades (Opcional) ---
# Caminho absoluto para o arquivo .csv que mapeia localidades do SNOW para entidades do GLPI
LOCATION_MAPPING_CSV_PATH=/path/to/your/location_mapping.csv
# Logging
LOG_LEVEL=info
LOG_TO_CONSOLE=true
LOG_RETENTION_DAYS=10
LOG_DIR=logs

View File

@ -0,0 +1,131 @@
name: Deploy production
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
name: Deploy to production VM
runs-on: vm-prod
env:
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight
shell: bash
run: |
set -euo pipefail
echo "Runner: $(hostname)"
node --version
npm --version
pm2 --version
rsync --version | head -n 1 || true
- name: Check required secrets
shell: bash
env:
ENV_PRODUCTION: ${{ secrets.ENV_PRODUCTION }}
run: |
set -euo pipefail
if [ -z "${ENV_PRODUCTION:-}" ]; then
echo "Missing secret ENV_PRODUCTION. Configure it in Settings > Actions > Secrets before deploying."
exit 1
fi
- name: Validate JavaScript
shell: bash
run: |
set -euo pipefail
find . -path ./node_modules -prune -o -name '*.js' -print | xargs -n1 node --check
- name: Write .env.production from secret
shell: bash
env:
ENV_PRODUCTION: ${{ secrets.ENV_PRODUCTION }}
run: |
set -euo pipefail
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
mkdir -p "$DEPLOY_PATH"
printf '%s\n' "$ENV_PRODUCTION" > "$DEPLOY_PATH/.env.production"
chmod 600 "$DEPLOY_PATH/.env.production"
chown desenvolvimento:desenvolvimento "$DEPLOY_PATH/.env.production"
- name: Deploy files and restart PM2
shell: bash
run: |
set -euo pipefail
# O checkout do runner (~/.cache/act/.../hostexecutor) so e legivel pelo root - o
# 'desenvolvimento' nem consegue entrar la. Por isso o rsync (que le de la e escreve em
# $DEPLOY_PATH) roda como root, igual sempre rodou. So DEPOIS de copiado a posse passa
# pro 'desenvolvimento' (chown), e dali em diante (npm ci, node --check, pm2) roda como
# 'desenvolvimento' via sudo -u - sem mudar o usuario do runner nem mexer em /opt em si
# (evita afetar outras automacoes deste servidor).
DEPLOY_PATH="${PROD_DEPLOY_PATH:-/opt/sn-glpi-sync-new}"
LOCK_FILE="/tmp/sn-glpi-sync-new.deploy.lock"
POST_SCRIPT="/tmp/sn-glpi-sync-new.deploy-post.sh"
mkdir -p "$DEPLOY_PATH"
mkdir -p "$DEPLOY_PATH.previous"
chown desenvolvimento:desenvolvimento "$DEPLOY_PATH.previous"
cat > "$POST_SCRIPT" <<'POST_EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$DEPLOY_PATH"
npm ci --omit=dev
find . -path ./node_modules -prune -o -name '*.js' -print | xargs -n1 node --check
pm2 startOrRestart ecosystem.config.js --env production
pm2 save
pm2 list
POST_EOF
chmod +x "$POST_SCRIPT"
(
flock -n 9 || {
echo "Another deploy is already running."
exit 1
}
if [ -d "$DEPLOY_PATH" ] && [ -n "$(ls -A "$DEPLOY_PATH" 2>/dev/null)" ]; then
rsync -a --delete "$DEPLOY_PATH"/ "$DEPLOY_PATH.previous"/
fi
rsync -az --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.env*' \
--exclude='node_modules/' \
--exclude='logs/' \
./ "$DEPLOY_PATH"/
chown -R desenvolvimento:desenvolvimento "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
test -f .env.production || {
echo "Missing $DEPLOY_PATH/.env.production"
exit 1
}
grep -q '^SERVICENOW_CAOA_TI_GROUP_ID=.\+' .env.production || {
echo "SERVICENOW_CAOA_TI_GROUP_ID must be set in $DEPLOY_PATH/.env.production"
exit 1
}
sudo -u desenvolvimento -H DEPLOY_PATH="$DEPLOY_PATH" bash "$POST_SCRIPT"
) 9>"$LOCK_FILE"
rm -f "$POST_SCRIPT"
- name: Show sync logs
if: always()
shell: bash
run: |
pm2 logs sn-glpi-sync-cron --nostream --lines 80 || true

5
.gitignore vendored
View File

@ -1,8 +1,11 @@
node_modules/
logs/*.log
logs/
config/*.json
.DS_Store
*.tmp
servicenow_requests.json
servicenow_tickets.json
location_mapping.log
.env*
!.env.example
docs/checklist.md

160
README.md
View File

@ -1,80 +1,138 @@
# servicenow-glpi-sync/README.md
# Sistema de Integracao ServiceNow <-> GLPI
# ServiceNow and GLPI Sync
Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando PostgreSQL como banco intermediario e acesso ao banco do GLPI.
This project is designed to facilitate the integration between ServiceNow and GLPI, allowing for the synchronization of tickets between the two platforms. It utilizes Node.js and follows the MVC architecture pattern.
## Estado atual
## Table of Contents
1. Coleta de tickets SN por watermark.
2. Criacao/vinculo de tickets no GLPI (grava `external_id` no GLPI com o numero do ticket SN).
3. Sincronizacao de comentarios e tasks em duas direcoes.
4. Preenchimento do campo "Ticket Externo" no SN com o ID GLPI.
5. Motor de regras unico (`processTicketLifecycleController`) para status/fechamento/reabertura:
`status=5` (resolver SN), `status=6` (encerrar definitivamente no SN), nota no GLPI quando o SN
resolve/encerra por conta propria (com aviso e troca de fila no SN), reabertura automatica e
regra de fora de escopo.
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Running the Application](#running-the-application)
- [Endpoints](#endpoints)
- [Logging](#logging)
- [Contributing](#contributing)
- [License](#license)
## Fluxo do ciclo
## Prerequisites
Executado pelo `main()`:
- Node.js (version 14 or higher)
- npm (Node Package Manager)
- PostgreSQL or MySQL database
- Access to ServiceNow and GLPI APIs
1. `processErrorController`
2. `processTicketsController`
3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
4. `processCommentsController`
## Installation
## Documentacao detalhada
1. Clone the repository:
1. Fluxo tecnico: [docs/fluxo.md](docs/fluxo.md)
2. Regras de negocio completas: [docs/regrasdenegocio.md](docs/regrasdenegocio.md)
3. Casos de uso: [docs/casosdeuso.md](docs/casosdeuso.md)
4. Manual do usuario (se eu fizer X, acontece Y): [docs/manualusuario.md](docs/manualusuario.md)
5. Plano e checklist de rollout: [docs/checklist.md](docs/checklist.md)
```
git clone https://github.com/yourusername/servicenow-glpi-sync.git
cd servicenow-glpi-sync
```
## Variaveis de ambiente importantes
2. Install the dependencies:
### Feature flags
```
npm install
```
1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
- `false`: desliga o motor inteiro
3. Copy the `.env.example` file to `.env` and fill in the required environment variables:
### ServiceNow
```
cp .env.example .env
```
1. `SERVICENOW_TABLE_INCIDENT_URL`
2. `SERVICENOW_TABLE_REQUEST_URL`
3. `SERVICENOW_TABLE_JOURNAL_URL`
4. `SERVICENOW_SC_ITEM_OPTION_URL`
5. `SERVICENOW_DEFAULT_USER`
6. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`
7. `SERVICENOW_EXTERNAL_TICKET_FIELD` (default: `u_external_ticket`)
## Configuration
### GLPI e banco intermediario
- Update the `.env` file with your ServiceNow and GLPI API credentials, database connection details, and any other necessary configurations.
1. `GLPI_DB_*`
2. `SNGLPI_DB_*`
3. `GLPI_OOS_SOLUTION_TYPE_ID`
## Running the Application
To start the application, run:
## Desenvolvimento
1. Instalar dependencias:
```bash
npm install
```
node server.js
2. Criar env:
```bash
copy .env.example .env.development
```
3. Executar:
```bash
npm run dev
```
The application will listen on the port specified in the `.env` file.
## Producao
## Endpoints
### Deploy manual na VM
- `POST /tickets/sync` - Synchronize tickets from ServiceNow to GLPI.
- `GET /tickets/:id` - Retrieve details of a specific ticket.
- `POST /tickets` - Create a new ticket in GLPI based on ServiceNow data.
- `PUT /tickets/:id` - Update an existing ticket in GLPI.
```bash
git pull origin master
npm ci --omit=dev
pm2 start ecosystem.config.js --env production
pm2 save
```
## Logging
Se o processo ja estiver rodando:
The application uses Winston for logging. Logs are stored in the `logs` directory:
```bash
pm2 restart sn-glpi-sync-cron --update-env
```
- `app.log` - Logs application events.
- `error.log` - Logs errors that occur during execution.
### Deploy via Gitea Actions
## Contributing
O workflow `.gitea/workflows/deploy-production.yml` roda no runner com rotulo `vm-prod`.
Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes.
1. Configure a variavel do repositorio `PROD_DEPLOY_PATH` com o caminho da aplicacao na VM de producao. Se nao configurar, o workflow usa `/opt/sn-glpi-sync-new`.
2. Garanta que o arquivo `.env.production` exista nesse caminho na VM e contenha `SERVICENOW_CAOA_TI_GROUP_ID` preenchido.
3. Faça push na branch `master` ou execute o workflow manualmente em Actions.
4. Acompanhe pelo Gitea Actions ou na VM:
## License
```bash
pm2 list
pm2 logs sn-glpi-sync-cron
```
This project is licensed under the MIT License. See the LICENSE file for details.
### Onde fica o `.env.production`
O `.env.production` nunca e versionado no git — o rsync do deploy exclui `.env*` de proposito,
exatamente para nunca sobrescrever esse arquivo com o codigo. O proprio workflow ja materializa
esse arquivo a cada deploy a partir do secret `ENV_PRODUCTION` (Settings > Actions > Secrets no
Gitea), entao a fonte da verdade das credenciais de producao e esse secret, nao um arquivo mantido
a mao na VM. Detalhes:
1. Ele existe dentro da propria pasta de deploy (`$PROD_DEPLOY_PATH/.env.production`, ex.:
`/opt/sn-glpi-sync-new/.env.production`) — nao na raiz de nenhum usuario (nem `dev`, nem
`root`). `cron.js`/`src/app.js` resolvem o caminho do env relativo a propria raiz do repo, entao
e ali que ele precisa estar.
2. O workflow ja aplica `chmod 600` nele apos escrever (dono = usuario que roda o PM2), evitando
outro usuario local da VM lendo credencial de banco/API em texto puro.
3. Pra atualizar uma credencial/config de producao, edite o secret `ENV_PRODUCTION` no Gitea e rode
o deploy de novo (push em `master` ou `workflow_dispatch`) — nao precisa mais SSH na VM pra
editar arquivo a mao. Como o secret fica guardado no Gitea (fora da VM), tambem resolve o risco
de perder a unica copia das credenciais se o disco da VM falhar.
### Rollback manual
O deploy guarda a versao anterior em `$PROD_DEPLOY_PATH.previous` antes de sincronizar o codigo
novo. Se o deploy subir uma versao com problema (o `node --check` so pega erro de sintaxe, nao de
logica), o rollback e:
```bash
rsync -a --delete "$PROD_DEPLOY_PATH.previous"/ "$PROD_DEPLOY_PATH"/
cd "$PROD_DEPLOY_PATH" && pm2 restart ecosystem.config.js --update-env
```
## Observacoes
1. O projeto ainda nao tem suite automatizada robusta.
2. O rollout recomendado e por fases com feature flags.
3. Nao remover schema legado antes de estabilizar o fluxo novo.
4. Consulte `docs/checklist.md` antes de cada deploy para validar pre e pos-subida.

View File

@ -1,4 +1,4 @@
require('dotenv').config();
//config/apiConfig.js
const glpiDbConfig = {
client: process.env.GLPI_DB_TYPE || 'mysql',
@ -24,11 +24,6 @@ const snIncidentConfig = {
baseUrl: process.env.SERVICENOW_INCIDENT_URL,
}
const snRequestConfig = {
// A URL base deve ser limpa, sem parâmetros. Os filtros serão adicionados no serviço.
baseUrl: process.env.SERVICENOW_REQUEST_URL,
}
const snTableIncidentConfig = {
baseUrl: process.env.SERVICENOW_TABLE_INCIDENT_URL,
}
@ -45,14 +40,18 @@ const snScItemOptionConfig = {
baseUrl: process.env.SERVICENOW_SC_ITEM_OPTION_URL,
}
const assignmentGroup = {
username: process.env.SERVICENOW_ASSIGNMENT_GROUP,
}
module.exports = {
// configuração do ServiceNow e outras já existentes podem permanecer
glpiDbConfig,
snIncidentConfig,
snRequestConfig,
snTableIncidentConfig,
snTableJournalConfig,
servicenowAuthentication,
snTableRequestConfig,
snScItemOptionConfig
snScItemOptionConfig,
assignmentGroup
};

View File

@ -1,5 +1,5 @@
const { Sequelize } = require('sequelize');
require('dotenv').config();
const { logInfo, logError } = require('../src/utils/logger');
// Database connection configuration
const dbConfig = {
@ -15,9 +15,9 @@ const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, proces
const testConnection = async () => {
try {
await sequelize.authenticate();
console.log('Connection to the database has been established successfully.');
logInfo('Connection to the database has been established successfully.');
} catch (error) {
console.error('Unable to connect to the database:', error);
logError('Unable to connect to the database:', error);
}
};

44
cron.js Normal file
View File

@ -0,0 +1,44 @@
const path = require('path');
const dotenv = require('dotenv');
const nodeEnv = process.env.NODE_ENV || 'development';
const envFile = nodeEnv === 'production' ? '.env.production' : '.env.development';
dotenv.config({ path: path.resolve(__dirname, envFile) });
const cron = require('node-cron');
const { main: runSyncCycle } = require('./src/app.js');
const { logInfo, logError } = require('./src/utils/logger.js');
let isCronRunning = false;
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '*/5 * * * *'; // A cada 5 minutos por padrão
logInfo(`⏰ Agendando ciclo de sincronização para rodar com a frequência: ${CRON_SCHEDULE}`);
cron.schedule(CRON_SCHEDULE, async () => {
if (isCronRunning) {
logInfo('CRON: Ciclo anterior ainda em execução. Pulando esta rodada.');
return;
}
isCronRunning = true;
logInfo('CRON: Iniciando ciclo de sincronização...');
try {
await runSyncCycle();
// A função main já loga sua própria mensagem de sucesso, então não precisamos duplicar aqui.
} catch (error) {
logError('CRON: Ocorreu um erro inesperado durante o ciclo de sincronização.', error);
} finally {
logInfo('CRON: Finalizando ciclo de sincronização.');
isCronRunning = false;
}
});
/**
* @file cron.js
* @description Responsável por agendar e executar a rotina de sincronização periodicamente.
* Este arquivo:
* 1. Carrega as variáveis de ambiente apropriadas para o ambiente (desenvolvimento/produção).
* 2. Agenda um `cron job` (usando node-cron) para executar a função principal de sincronização.
* 3. Garante que apenas uma instância do ciclo de sincronização rode por vez.
*/

165
docs/RODAMP.MD Normal file
View File

@ -0,0 +1,165 @@
# Roadmap de Evolucao - SN <-> GLPI
## Visao Geral
Objetivo: elevar confiabilidade, observabilidade e arquitetura da integracao sem interromper operacao.
Principios:
- migracao incremental (sem Big Bang)
- manter compatibilidade com fluxo atual
- cada fase precisa ter criterio claro de aceite
## Fases (ordem recomendada)
### Fase 0 - Baseline e seguranca operacional (1 semana)
Objetivo: criar base para evolucao segura.
Entregas:
- checklist oficial de deploy/rollback em producao
- backup e restore testado para banco intermediario
- playbook de incidentes (falha SN, falha GLPI, falha DB)
- padrao de versionamento e convencao de commits
Criterios de aceite:
- rollback executado com sucesso em ambiente de teste
- documentacao revisada e aprovada
Risco: baixo
---
### Fase 1 - Logs estruturados e rastreabilidade (1-2 semanas)
Objetivo: saber exatamente onde e por que falhou.
Entregas:
- padrao de log com contexto por camada e direcao:
- `[USECASE][GLPI>SN]`
- `[USECASE][SN>GLPI]`
- `[SERVICE]`, `[REPOSITORY]`, `[INTEGRATION]`
- `correlation_id` por ciclo e por ticket
- campos padrao nos logs: `ticket_number`, `sn_ticket_id`, `glpi_ticket_id`, `source_last`, `step`
- metricas minimas por ciclo: processados, sucesso, erro, duracao
Criterios de aceite:
- conseguir rastrear 1 ticket de ponta a ponta por `correlation_id`
- reduzir tempo de diagnostico de erro em pelo menos 50%
Risco: baixo
---
### Fase 2 - Estabilizacao final de fluxos (1 semana)
Objetivo: fechar lacunas de negocio antes de refatoracao pesada.
Entregas:
- bateria manual oficial para `incidente` e `requisicao`
- validacao de regras finais:
- resolucao GLPI -> SN
- reabertura SN -> GLPI
- fechamento permanente GLPI nao reabre
- fora de escopo nao reabre
- hardening de idempotencia em comentarios/tarefas
Criterios de aceite:
- 100% do checklist de fluxos principais aprovado em DEV
Risco: baixo
---
### Fase 3 - Modularizacao progressiva (monolito modular, estilo clean) (3-5 semanas)
Objetivo: organizar o sistema em modulos com fronteiras claras.
Entregas:
- estrutura em camadas:
- `domain` (regras e entidades)
- `application` (casos de uso)
- `infrastructure` (DB, APIs, logger)
- `interfaces` (cron/controllers)
- definicao de portas/adapters para SN, GLPI e repositorios
- migracao por vertical (ticket -> comment -> status)
Criterios de aceite:
- novos casos de uso entram sem acoplar controller direto a SQL/API
- arquitetura documentada em diagrama simples
Risco: medio
---
### Fase 4 - GLPI via API (2-4 semanas)
Objetivo: reduzir dependencia de escrita direta no banco GLPI.
Entregas:
- cliente GLPI API para:
- criar ticket
- inserir comentario
- inserir tarefa
- atualizar status
- fallback controlado para SQL apenas enquanto necessario
- comparativo de comportamento API x SQL em DEV
Criterios de aceite:
- ao menos 1 fluxo completo operando via API com sucesso
- sem regressao de regras de negocio
Risco: medio
---
### Fase 5 - Sincronizacao de imagens/anexos (3-6 semanas)
Objetivo: suportar anexos dos dois lados.
Entregas:
- modelagem de rastreio de anexos (source_id, destiny_id, hash, status)
- pipeline SN -> GLPI e GLPI -> SN:
- listar anexos
- baixar binario
- validar tipo/tamanho
- upload no destino
- deduplicacao por hash
- retries com backoff
Criterios de aceite:
- anexos de teste sobem nos dois sentidos com rastreabilidade
- sem duplicacao de anexos no reprocessamento
Risco: alto
---
### Fase 6 - Qualidade e teste automatizado (2-4 semanas, em paralelo)
Objetivo: diminuir risco de regressao.
Entregas:
- testes unitarios de mapeamento/status/regras
- testes de integracao com mocks de SN e GLPI
- smoke test automatizado por ciclo
Criterios de aceite:
- cobertura minima em regras criticas
- pipeline bloqueando merge em caso de regressao
Risco: medio
## Backlog Tecnico (prioridade)
P1:
- logs estruturados + correlation_id
- checklist oficial de deploy/rollback
- bateria de testes de `requisicao`
P2:
- modularizacao por casos de uso
- GLPI API para comentarios/tarefas
P3:
- anexos/imagens bidirecional
- observabilidade avancada (dashboards/alertas)
## Definicao de Pronto (DoD) por fase
- codigo revisado
- documentacao atualizada
- evidencias de teste anexadas
- plano de rollback definido
## Observacao
Este roadmap prioriza estabilidade de operacao antes de expansao funcional. A transicao para arquitetura modular deve ser gradual e guiada por casos de uso reais.

153
docs/casosdeuso.md Normal file
View File

@ -0,0 +1,153 @@
# Casos de Uso
## Capitulo 1 - Abertura do chamado
No reino da CAOA, um usuario abre um chamado no ServiceNow.
O oraculo da integracao observa o novo registro e grava no banco intermediario.
Sem demora, o chamado atravessa o portal SN -> GLPI.
No GLPI, o ticket nasce com seu vinculo e recebe seu identificador.
De volta ao ServiceNow, o campo "Ticket Externo" e preenchido com o ID GLPI.
Resultado esperado:
1. Chamado criado no GLPI.
2. `ticket_sync` em `synced/synced`.
3. Ticket Externo preenchido no SN.
---
## Capitulo 2 - O chamado fora do escopo
Um atendente da Sothis analisa o chamado e percebe:
"Problema relacionado a Rede e nao Telefonia".
Ele registra essa justificativa na nota de solucao do GLPI (fora do escopo).
A integracao reconhece o tipo de solucao configurado para fora de escopo.
Entao envia a justificativa para o ServiceNow como nota.
Nao encerra o chamado no SN nesse passo de negocio de fora de escopo.
O chamado sai do monitoramento automatico da integracao.
Estado final esperado no banco intermediario:
1. `glpi_sync_status = 'closed'`
2. `sn_sync_status = 'ignored'`
Interpretacao operacional:
1. Chamado nao sera mais tratado pela fila automatica da integracao.
2. Evita retrabalho e loops de sincronizacao.
---
## Capitulo 3 - Encerramento definitivo
Quando um chamado chega ao encerramento definitivo no GLPI (`status=6`),
o cron de fechamento detecta o estado final e executa:
1. Publica no SN a mensagem:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
2. Nao altera o status do chamado no SN.
3. Marca `ticket_sync` como `closed/closed` para retirar do monitoramento.
Resultado esperado:
1. Nao fica chamado fechado no GLPI e aberto no SN.
2. Nao ocorre tentativa de reabertura automatica para GLPI `6`.
---
## Capitulo 4 - Comunicacao entre os reinos (comentarios)
No salao da CAOA (ServiceNow), o usuario escreve:
"Equipe Sothis, conseguimos reproduzir o erro as 14h10."
A mensagem atravessa o portal SN -> GLPI e aparece como followup.
Do outro lado, no castelo da Sothis (GLPI), o atendente responde:
"Recebido. Estamos validando o circuito."
A resposta volta pelo portal GLPI -> SN como comentario.
Os escribas da integracao registram origem e destino para evitar duplicidade.
Resultado esperado:
1. Comentario SN vira followup GLPI.
2. Followup GLPI vira comentario SN.
3. Sem mensagens duplicadas no vai-e-volta.
---
## Capitulo 5 - As tarefas e os segredos do reino
No reino da CAOA, toda `work_note` do SN e uma ordem de batalha.
Essas ordens sao enviadas ao GLPI como tarefas visiveis para o time tecnico.
No reino da Sothis, tarefas internas do GLPI sao consideradas secretas.
Elas nao atravessam o portal para o SN.
Somente followups/comentarios publicos participam da comunicacao entre reinos.
Resultado esperado:
1. Toda `work_note` SN vira task GLPI.
2. Task criada no GLPI nao volta para SN.
3. O SN recebe somente comentarios/followups do GLPI.
---
## Capitulo 6 - A solucao no reino Sothis (GLPI)
Quando o atendente Sothis conclui o trabalho tecnico no GLPI,
ele registra a solucao e coloca o chamado em **Solucionado** (`status=5`).
O cron da integracao observa esse estado e realiza:
1. Busca a nota de solucao do GLPI.
2. Resolve o chamado correspondente no SN.
3. Atualiza o estado local para `solved/solved`.
Se o chamado no SN estiver em estado que bloqueia encerramento
(como espera/aguardando), a integracao primeiro ajusta para em atendimento
e depois aplica a resolucao.
Resultado esperado:
1. GLPI solucionado reflete SN resolvido.
2. A nota de resolucao do GLPI vira base do fechamento no SN.
3. Chamado continua monitorado ate o encerramento definitivo.
---
## Capitulo 7 - A solucao vinda do reino CAOA (SN)
Quando a CAOA marca o chamado como resolvido no SN,
a integracao **nao fecha o chamado no GLPI automaticamente**.
Em vez disso:
1. Uma nota informativa e adicionada no GLPI.
2. O chamado sai do monitoramento automatico de status.
Essa regra evita conflito de fluxo e protege o time tecnico de mudancas
de estado inesperadas do outro reino.
Resultado esperado:
1. GLPI recebe contexto da resolucao do SN.
2. Nao ocorre fechamento forcado no GLPI por esse evento.
3. Fluxo fica estavel para evitar reaberturas em cadeia.
---
## Capitulo 8 - Regras de ouro para os guardioes dos dois reinos
1. Comentario e diplomacia entre reinos: sempre pode cruzar o portal.
2. Task do SN e ordem operacional para Sothis: cruza para o GLPI.
3. Task interna do GLPI e sigilo tecnico: nao cruza para SN.
4. GLPI `5` resolve SN.
5. GLPI `6` encerra monitoramento e publica aviso definitivo no SN.
6. Reabertura automatica:
- permitida se GLPI estiver `5`
- bloqueada se GLPI estiver `6`
7. Fora de escopo:
- justifica no SN
- remove do monitoramento
- estado final `closed/ignored`.

89
docs/fluxo.md Normal file
View File

@ -0,0 +1,89 @@
# Fluxo Operacional
## 1. Fluxo do ciclo (`main`)
1. `processErrorController`
2. `processTicketsController`
3. `processTicketLifecycleController` (se `ENABLE_GLPI_CLOSE_CRON=true`) — motor de regras unico
pra status/fechamento/reabertura, ver secao 4.
4. `processCommentsController`
## 2. Fluxo SN -> GLPI
1. Coleta incidentes/requisicoes no SN por watermark.
2. UPSERT em `tickets_sn`.
3. Cria/atualiza `ticket_sync` com estados iniciais.
4. Cria ticket no GLPI quando `pending_check`.
5. Grava `glpi_ticket_id` em `ticket_sync`.
6. Atualiza campo "Ticket Externo" no SN com ID GLPI.
7. Se falhar atualizar "Ticket Externo", agenda retry em `ticket_updates`.
## 3. Fluxo de comentarios/tasks
### 3.1 SN -> GLPI
1. Busca `comments` e `work_notes` no SN.
2. Persiste em `ticket_updates` (idempotente por `source_id`).
3. Envia:
- `comment` -> followup GLPI
- `task` -> task GLPI
### 3.2 GLPI -> SN
1. Busca followups GLPI.
2. Sanitiza conteudo HTML/imagens.
3. Envia comentario para SN evitando duplicidade.
4. Atualiza `destiny_id` para rastrear espelhamento.
## 4. Motor de regras unico (`processTicketLifecycleController`)
Substitui os antigos `processGlpiClosureController` + `processStatusController`. Nao ha mais dois
controladores decidindo o mesmo ticket, e nenhuma decisao depende de `source_last` (campo mantido
so como rastro de auditoria). Roda pra todo ticket com `glpi_ticket_id` que nao esteja
`closed`/`ignored`/`error_permanent`:
1. **GLPI status `5` (Solucionado)**:
- se o ticket ja estava `solved` localmente, so verifica se o SN foi reaberto (ver secao 5) e
para por ai;
- senao: verifica fora de escopo; se nao for, busca nota de solucao, se SN estiver em
espera/aguardando move pra "Em Atendimento", resolve o SN **confirmando via leitura ao vivo**
antes de marcar `solved/solved` (nunca marca so por causa de um HTTP 200).
2. **GLPI status `6` (Fechado definitivamente)**:
- adiciona comentario idempotente no SN: "Chamado encerrado definitivamente. Reabertura deste
chamado nao sera atendida. Caso necessario, abra um novo chamado."
- nao altera status do chamado no SN
- marca `ticket_sync` como `closed/closed` para retirar do monitoramento
3. **GLPI ainda aberto (`< 5`) e SN resolvido/encerrado por conta propria**:
- adiciona nota idempotente no GLPI avisando o encerramento vindo do SN, sem fechar o chamado
(inclui o nome de quem encerrou no SN, buscado ao vivo: `resolved_by` -> `closed_by` ->
`sys_updated_by`, o que vier preenchido primeiro)
- (primeira vez apenas) avisa no proprio SN que a Sothis nao vai mais atender esse chamado e
transfere pra fila CAOA
- marca `ticket_sync` como `solved/solved` ou `closed/closed`
4. **GLPI ainda aberto (`< 5`) e SN tambem em status intermediario** (nenhum dos dois em
Resolvido/Encerrado): aplica o mandante, se configurado — ver secao 6.
## 5. Reabertura
1. Reabertura automatica permitida apenas para tickets em GLPI `5` — verificado a cada ciclo pelo
mesmo `processTicketLifecycleController`, sem depender de nenhuma flag.
2. Se GLPI `6`, reabertura automatica bloqueada (a regra nem entra nesse caminho).
## 6. Mandante (regras 19-22)
Espelhamento **opcional** de status intermediario (`Aguardando Atendimento`/`Em Atendimento`/
`Em Espera` no SN, equivalentes ao GLPI `1`/`2`/`4` — GLPI `3` tambem mapeia para
`Em Atendimento`). Nunca toca em status finais (5, 6, fora de escopo) — essas continuam sendo as
regras fixas das secoes 4.1-4.3, sempre ativas.
Controlado pelo env `MANDANTE`:
1. `MANDANTE=GLPI`: quando o status intermediario do GLPI muda, o SN e atualizado pra
acompanhar. Mudancas de status intermediario feitas diretamente no SN sao ignoradas.
2. `MANDANTE=SNOW`: o oposto — o SN manda, o GLPI acompanha.
3. `MANDANTE` vazio/ausente/invalido: nenhum espelhamento (comportamento padrao, igual a antes do
mandante existir).
Em qualquer configuracao, so escreve quando o valor desejado difere do atual (evita chamada de API
desnecessaria) e nunca le o status do lado seguidor pra decidir nada — o mandante so manda, nunca
escuta, entao nao ha risco de loop entre os dois sistemas.

103
docs/manualusuario.md Normal file
View File

@ -0,0 +1,103 @@
# Manual do Usuario - Integracao ServiceNow <-> GLPI
Este documento e um guia pratico pra quem usa o ServiceNow (SN) ou o GLPI no dia a dia e quer saber
"se eu fizer X, o que acontece do outro lado?". Nao e um documento tecnico - pra detalhes de
implementacao, ver `docs/regrasdenegocio.md` e `docs/fluxo.md`.
## 1. Abertura de chamado
- Se um chamado novo e aberto no ServiceNow, atribuido ao grupo Sothis, entao um chamado
correspondente e criado automaticamente no GLPI.
- O campo "Ticket Externo" no SN e preenchido com o numero do chamado GLPI logo apos a criacao.
## 2. Comentarios e tarefas
- Se voce escreve um comentario no SN, entao ele aparece como um followup no GLPI.
- Se o time tecnico escreve um followup/comentario no GLPI, entao ele aparece como comentario no SN
(visivel pro solicitante).
- Se uma work note e registrada no SN, entao ela vira uma tarefa (task) no GLPI.
- Se uma task e criada no GLPI, entao ela **nao** aparece no SN - tasks do GLPI sao uso interno do
time tecnico, nao chegam ao solicitante.
- Comentarios e tarefas nunca duplicam, mesmo se o sistema processar o mesmo chamado varias vezes
seguidas.
- Hoje a integracao so sincroniza texto - imagens e anexos nao atravessam de um sistema pro outro.
## 3. Quando o GLPI resolve o chamado
- Se o time tecnico marca o chamado como "Solucionado" no GLPI, entao o chamado e resolvido
automaticamente no ServiceNow tambem, usando a nota de solucao do GLPI como base.
- Se o chamado no SN estava em "Em Espera" ou "Aguardando Atendimento" nesse momento, entao ele e
movido primeiro pra "Em Atendimento" e so depois resolvido (o SN exige isso pra permitir a
resolucao).
## 4. Quando o GLPI encerra definitivamente o chamado
- Se o time tecnico marca o chamado como "Fechado" no GLPI, entao um aviso e publicado
automaticamente no SN: "Chamado encerrado definitivamente. Reabertura deste chamado nao sera
atendida. Caso necessario, abra um novo chamado."
- O status do chamado no SN **nao** e alterado por esse evento - so o comentario e adicionado.
- O chamado sai do monitoramento automatico da integracao - nenhuma acao futura acontece nele.
## 5. Reabertura de chamado
- Se o solicitante reabre o chamado no SN e o chamado no GLPI estava "Solucionado", entao o GLPI
tambem volta pra "Em Atendimento" automaticamente.
- Se o chamado no GLPI ja estava "Fechado" (encerramento definitivo), entao a reabertura **nao** e
refletida no GLPI - nesse caso e preciso abrir um chamado novo.
## 6. Quando o SN resolve ou encerra por conta propria (antes do GLPI)
Se o solicitante ou alguem no SN resolve/encerra o chamado, mas o GLPI ainda nao tinha chegado em
"Solucionado"/"Fechado", o GLPI **nao** fecha automaticamente. Em vez disso:
1. Uma nota e adicionada no GLPI avisando que o chamado foi resolvido/encerrado pelo SN (com o nome
de quem fez isso).
2. Um aviso e publicado de volta no SN dizendo que a Sothis nao vai mais atender esse chamado.
3. O chamado e transferido pra fila de TI da CAOA no SN.
4. O chamado sai do monitoramento automatico da integracao.
## 7. Chamados fora do escopo da Sothis
- Se o time tecnico usa o tipo de solucao "fora de escopo" ao solucionar no GLPI, entao:
1. A justificativa e enviada como nota pro SN.
2. O grupo responsavel no GLPI e removido do chamado.
3. O chamado e transferido pra fila de TI da CAOA no SN.
4. O chamado sai do monitoramento automatico - isso nao e um encerramento normal.
## 8. Mandante - espelhamento de status intermediario (recurso opcional, hoje desligado)
- Por padrao, mudancas de status "no meio do caminho" (Aguardando Atendimento / Em Atendimento / Em
Espera) em qualquer um dos dois sistemas **nao** sao refletidas automaticamente no outro - cada
time so ve as mudancas que ele mesmo fizer no proprio sistema.
- Existe uma configuracao (`MANDANTE`) que pode ligar esse espelhamento numa direcao so - fazendo um
sistema "mandar" nesses status intermediarios sobre o outro. **Hoje esse recurso esta desligado em
producao.**
- Esse recurso, mesmo quando ligado, nunca muda o comportamento dos itens 3 a 7 acima (resolucao,
fechamento definitivo, fora de escopo, reabertura) - essas regras sao sempre fixas.
## 9. O que nao acontece automaticamente hoje
- Imagens e anexos nao sao sincronizados entre os sistemas (nem SN -> GLPI, nem GLPI -> SN), so
texto.
- Um chamado com erro de sincronizacao e reprocessado automaticamente, mas so ate um numero maximo
de tentativas. Depois disso ele fica marcado pra intervencao manual - vale avisar o time tecnico
se um chamado parecer "parado".
- Mudanca de status intermediario (item 8) so e refletida entre os sistemas se o recurso Mandante
estiver ligado - por padrao, nao esta.
## 10. Resumo rapido
| Se voce... | O que acontece |
| --- | --- |
| Abre um chamado no SN pro grupo Sothis | Cria automaticamente no GLPI |
| Comenta no SN | Vira followup no GLPI |
| Comenta/responde no GLPI | Vira comentario no SN |
| Cria work note no SN | Vira task no GLPI |
| Cria task no GLPI | Fica so no GLPI, nao vai pro SN |
| Marca "Solucionado" no GLPI | Resolve automaticamente no SN |
| Marca "Fechado" no GLPI | Publica aviso definitivo no SN, sem reabertura |
| Reabre no SN (GLPI estava Solucionado) | GLPI volta pra Em Atendimento |
| Reabre no SN (GLPI estava Fechado) | Nao reflete - precisa abrir chamado novo |
| Resolve/encerra no SN antes do GLPI | GLPI recebe nota, SN recebe aviso de que a Sothis nao atende mais e vai pra fila CAOA |
| Marca solucao "fora de escopo" no GLPI | Chamado sai do fluxo Sothis, vai pra fila CAOA no SN |
| Muda status intermediario em qualquer sistema | Nao reflete no outro, a menos que o Mandante esteja ligado (hoje desligado) |

228
docs/regrasdenegocio.md Normal file
View File

@ -0,0 +1,228 @@
# Regras de Negocio Completas
## 1. Escopo funcional da integracao
1. A origem oficial de novos chamados e o ServiceNow (SN).
2. O GLPI recebe chamados criados no SN e passa a participar do atendimento.
3. A integracao cobre:
- criacao/vinculo de chamados SN -> GLPI
- sincronizacao de comentarios (SN <-> GLPI)
- sincronizacao de tasks (SN -> GLPI via `work_notes`)
- fechamento definitivo GLPI -> SN (status GLPI = 6)
4. Sincronizacao legada bidirecional de status existe, mas deve ficar desligada no modelo novo (por flag).
## 2. Governanca e responsabilidade entre sistemas
1. SN e a origem da demanda.
2. GLPI e o sistema tecnico de atendimento e encerramento final.
3. Banco intermediario (PostgreSQL) e a fonte de controle operacional da integracao.
## 3. Orquestracao do ciclo
1. O ciclo roda por `cron`.
2. Ordem de execucao:
- `processErrorController`
- `processTicketsController`
- `processTicketLifecycleController` (motor de regras unico de status/fechamento/reabertura, se habilitado)
- `processCommentsController`
3. Ha protecao para evitar execucao concorrente do mesmo ciclo no mesmo processo.
4. Nao ha mais dois controladores decidindo status do mesmo ticket — o fluxo legado
(`processStatusController`) foi removido e sua logica util (nota SN->GLPI, fora de escopo) foi
absorvida pelo motor unico.
## 4. Feature flags e comportamento
1. `ENABLE_GLPI_CLOSE_CRON`
- `true`: executa o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`).
- `false`: desliga o motor inteiro.
2. `ENABLE_GLPI_WEBHOOK`
- reservado para rollout por evento (planejado), sem obrigatoriedade no fluxo atual.
## 5. Regras de coleta de tickets no ServiceNow
1. Busca incidentes e requisicoes por `assignment_group` e `sys_updated_on >= watermark`.
2. Watermark vem de `sync_control`.
3. Novo watermark usa maior `sys_updated_on` encontrado no ciclo.
4. Watermark salvo aplica margem de seguranca de 6 horas para tras.
5. Se nao houver tickets novos/atualizados, watermark nao e alterado.
## 6. Regras de persistencia local (`tickets_sn`)
1. UPSERT por `ticket_number`.
2. `sys_id` e unico.
3. Campos de incidente e requisicao tem mapeamento diferente.
4. Para requisicao, pode haver enriquecimento por variaveis de catalogo:
- justificativa
- telefone
5. `updated_at` e atualizado automaticamente (trigger no banco).
## 7. Regras de criacao de estado de sincronizacao (`ticket_sync`)
1. Novo ticket entra com `sn_ticket_id` vinculado.
2. Se status SN vier final (`Encerrado` ou `Encerrado - Omitido`):
- `sn_sync_status = closed`
- `glpi_sync_status = ignored`
3. Se status SN vier aberto:
- `sn_sync_status = collected`
- `glpi_sync_status = pending_check`
4. `source_last` existe no schema e continua sendo gravado como rastro de auditoria, mas nao e mais lido em nenhum lugar do codigo para decidir status.
## 8. Regras de criacao/vinculo no GLPI
1. Apenas tickets com `glpi_sync_status = pending_check` entram na criacao/vinculo.
2. Se `glpi_ticket_id` ja existir no banco intermediario:
- manter `synced/synced`
3. Se nao houver `glpi_ticket_id` local:
- procurar ticket no GLPI por numero SN no titulo/conteudo
4. Se encontrar ticket no GLPI:
- vincular `glpi_ticket_id`
- se status GLPI=6, marcar `ignored/closed` e nao monitorar
- caso contrario, marcar `synced/synced`
5. Se nao encontrar:
- criar ticket no GLPI
- vincular e marcar `synced/synced`
6. Erro de criacao:
- marcar estado de erro para reprocessamento.
## 9. Regras de formatacao ao criar ticket no GLPI
1. Titulo segue padrao: tipo + entidade + short_description.
2. Entidade vem de `location_mapping`; fallback para entidade padrao.
3. Descricao e montada em HTML com dados de solicitante.
4. Requisicao sem justificativa/descricao pode usar valores default configurados.
5. Prioridade, categoria e SLA usam mapeamentos predefinidos.
## 10. Regra do campo "Ticket Externo" no SN
1. Ao criar/vincular ticket no GLPI, escrever ID GLPI no SN.
2. Campo usado e configuravel por `SERVICENOW_EXTERNAL_TICKET_FIELD` (default `u_external_ticket`).
3. Se a escrita falhar, nao recriar ticket no GLPI.
4. Falha gera pendencia de retry idempotente em `ticket_updates` (`update_type=external_link`).
5. Retry processa pendencias com `destiny_id IS NULL`.
6. Ao sucesso, `destiny_id` da pendencia vira `done`.
## 11. Regras de sincronizacao de comentarios SN -> GLPI
1. Busca journal entries `comments` e `work_notes` do SN.
2. Ordena cronologicamente antes de enviar.
3. Idempotencia por `ticket_updates.source_id` (sys_id do journal).
4. `comments` viram followup no GLPI.
5. `work_notes` viram task no GLPI.
6. Conteudo enviado para GLPI e formatado com card visual e autor.
7. Em falha de envio, registra estado de erro para novo ciclo.
## 12. Regras de sincronizacao de comentarios GLPI -> SN
1. Busca followups do GLPI ignorando usuario padrao tecnico da integracao.
2. Sanitiza HTML, metadados e imagens.
3. Evita duplicidade com verificacao local e remota.
4. Atualiza mapeamento origem/destino em `ticket_updates.destiny_id`.
5. Ao final, evita deixar ticket preso em estado transitorio de comentario.
## 13. Regras de filtro de usuario padrao no SN (ambiente de teste)
1. `SERVICENOW_DEFAULT_USER` define usuario da integracao.
2. `SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL`:
- `true` (padrao): ignora journals desse usuario para evitar loop.
- `false`: nao ignora (util para dev com usuario unico).
## 14. Regras de fechamento e resolucao
1. Fechamento definitivo GLPI `status=6` e refletido no SN via `processTicketLifecycleController`.
2. Ao detectar GLPI=6:
- enviar comentario no SN:
- "Chamado encerrado definitivamente. Reabertura deste chamado nao sera atendida. Caso necessario, abra um novo chamado."
- nao alterar status do chamado no SN
- marcar `ticket_sync` como `closed/closed` para retirar do monitoramento
3. Aviso de encerramento definitivo no SN e idempotente por `source_id` em `ticket_updates`.
4. Quando o SN resolve/encerra por conta propria e o GLPI ainda nao chegou em 5/6: nota idempotente
no GLPI + aviso no SN (Sothis nao vai mais atender) + transferencia pra fila CAOA — ver
`docs/fluxo.md` secao 4.3. Nao depende de `source_last` nem de nenhuma flag de sincronizacao
legada (removida).
## 15. Regras de fora do escopo (Sothis)
1. Quando solucao no GLPI usa `solutiontypes_id = GLPI_OOS_SOLUTION_TYPE_ID`:
- envia justificativa ao SN em nota
- nao segue fluxo normal de atendimento
- estado final esperado no intermediario:
- `glpi_sync_status = closed`
- `sn_sync_status = ignored`
2. Chamado sai do monitoramento automatico.
## 16. Regras de estados monitorados vs terminais
### 16.1 Monitorados
1. `pending_check`
2. `synced`
3. `solved`
4. `error` e variantes de erro para reprocesso
### 16.2 Terminais
1. `closed`
2. `ignored`
3. `error_permanent` — ticket excedeu `MAX_ERROR_RETRIES` tentativas automaticas de reprocessamento
e exige intervencao manual.
Regra geral: `closed`, `ignored` e `error_permanent` nao participam das rotinas normais de
sincronizacao.
## 17. Regra de reabertura
1. Reabertura automatica permitida apenas quando GLPI estiver em `5` (solved).
2. Se GLPI estiver em `6` (fechado definitivo), reabertura automatica nao e permitida.
## 18. Regras de idempotencia e rastreabilidade
1. `ticket_updates.source_id` e unico.
2. Toda sincronizacao relevante registra origem e destino quando possivel.
3. `ticket_sync.last_sn_sync` e `last_glpi_sync` registram ultima interacao por sistema.
## 19. Regras de erro e reprocessamento
1. Inicio de ciclo executa reset de estados de erro processaveis.
2. Erro em etapa nao deve causar duplicidade de criacao.
3. Tickets em erro voltam para fila valida de processamento conforme tipo do erro.
## 20. Regras de monitoramento operacional
1. Logs devem permitir rastrear:
- criacao de ticket
- sincronizacao de comentario/task
- fechamento definitivo
- falhas e retries
2. Rollout deve ser faseado por flag e com possibilidade de rollback.
## 21. Regras de schema (situacao atual)
1. Nao remover colunas legadas neste momento.
2. Nao criar tabela nova para eventos nesta fase.
3. Reaproveitar `ticket_updates` para pendencias e idempotencia.
## 22. Criterios de sucesso de negocio
1. Ticket fechado definitivamente no GLPI nao permanece aberto no SN.
2. Comentarios e tasks nao duplicam.
3. Tickets fora do escopo saem de monitoramento com estado final correto.
4. Integracao nao entra em loop por status intermediario.
## 23. Regras de mandante (espelhamento de status intermediario)
1. Existe um sistema "mandante" configuravel via env `MANDANTE` (`GLPI`, `SNOW` ou vazio) cujo
status intermediario (`Aguardando Atendimento`/`Em Atendimento`/`Em Espera` no SN, GLPI
`1`/`2`/`3`/`4`) e espelhado automaticamente no outro sistema.
2. Nunca se aplica aos status finais (GLPI `5`/`6`, fora de escopo) - essas continuam sendo as
regras fixas 10-15, sempre ativas independente do mandante.
3. `MANDANTE` vazio, ausente ou com valor invalido mantem o comportamento padrao: nenhum
espelhamento de status intermediario (equivalente ao que a regra de contencao ja fazia antes do
mandante existir).
4. O mandante e unidirecional: so escreve no sistema seguidor, nunca le o status do seguidor para
decidir algo - evita loop de espelhamento entre os dois sistemas.
5. So escreve quando o valor espelhado realmente difere do atual, para nao gerar chamada de API
desnecessaria a cada ciclo.

83
ecosystem.config.js Normal file
View File

@ -0,0 +1,83 @@
module.exports = {
apps: [
// 🕒 --- APLICAÇÃO 1: CRON JOB DE SINCRONIZAÇÃO (Node.js) ---
{
name: 'sn-glpi-sync-cron', // Nome que aparecerá no PM2
script: 'cron.js', // Caminho do arquivo principal que executa o cron
// 👇 Modo "fork" = apenas 1 instância, sem cluster (evita rodar crons duplicados)
exec_mode: 'fork',
instances: 1, // Força a rodar somente um processo
// ⚙️ Variáveis de ambiente padrão (modo development)
env: {
NODE_ENV: 'development',
},
// ⚙️ Variáveis de ambiente quando rodar com `--env production`
env_production: {
NODE_ENV: 'production',
// Você pode sobrescrever outras variáveis aqui para produção, se necessário
// Ex: CRON_SCHEDULE: '*/2 * * * *'
},
// Configurações de log
output: './logs/pm2-out.log',
error: './logs/pm2-error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
},
// 🗺️ --- APLICAÇÃO 2: MAPEADOR DE LOCALIDADES (Python) ---
{
name: 'sn-glpi-location-mapper', // Nome do serviço do mapeador
script: 'src/scripts/python/update_location_mapping.py', // Caminho do script Python
interpreter: 'python3', // Especifica o interpretador a ser usado
exec_mode: 'fork',
instances: 1,
autorestart: true, // Reinicia automaticamente em caso de falha
watch: false, // O script já roda em loop, não precisa de watch
// Garante que o script Python também use as variáveis de ambiente corretas
env: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
},
// Deixamos o script Python gerenciar seus próprios arquivos de log
output: '/dev/null',
error: '/dev/null',
},
],
};
/**
* @file ecosystem.config.js
* @description Define a configuração do PM2 para gerenciar a aplicação de sincronização.
*
* - `sn-glpi-sync-cron`: Serviço Node.js que executa o ciclo principal de sincronização.
* - `sn-glpi-location-mapper`: Serviço Python que monitora e atualiza o mapeamento de localidades.
* Ambos rodam em modo `fork` para garantir que
* apenas uma instância das tarefas agendadas seja executada por vez, evitando duplicidade.
*
* 💡 Dicas de uso:
*
* 1. Iniciar a aplicação:
* // Para ambiente de desenvolvimento
* pm2 start ecosystem.config.js --env development
*
* // Para ambiente de produção
* pm2 start ecosystem.config.js --env production
*
* 2. Comandos úteis:
* pm2 list // Lista todos os processos gerenciados pelo PM2
* pm2 logs <name> // Exibe os logs de um processo (ex: pm2 logs sn-glpi-sync-cron)
* pm2 monit // Abre um painel de monitoramento
* pm2 restart sn-glpi-sync-cron // Reinicia a aplicação
* pm2 stop sn-glpi-sync-cron // Para a aplicação
* pm2 delete sn-glpi-sync-cron // Remove a aplicação da lista do PM2
*
* 3. Para iniciar com o sistema operacional (em produção):
* pm2 startup
* pm2 save
*/

499
package-lock.json generated
View File

@ -13,8 +13,14 @@
"dotenv": "^10.0.0",
"express": "^5.1.0",
"mysql2": "^3.14.5",
"node-cron": "^4.2.1",
"pg": "^8.7.1",
"winston": "^3.17.0"
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"cross-env": "^7.0.3",
"nodemon": "^3.1.4"
}
},
"node_modules/@colors/colors": {
@ -56,6 +62,20 @@
"node": ">= 0.6"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -80,6 +100,26 @@
"follow-redirects": "^1.14.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@ -100,6 +140,30 @@
"node": ">=18"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -138,6 +202,31 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@ -183,6 +272,13 @@
"text-hex": "1.0.x"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@ -222,6 +318,39 @@
"node": ">=6.6.0"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -394,6 +523,28 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@ -455,6 +606,21 @@
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -510,6 +676,19 @@
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -522,6 +701,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -583,6 +772,13 @@
"node": ">=0.10.0"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -604,6 +800,52 @@
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -628,6 +870,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
@ -732,6 +981,28 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -795,6 +1066,63 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -846,6 +1174,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
@ -945,6 +1283,19 @@
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -997,6 +1348,13 @@
"node": ">= 0.10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -1050,6 +1408,19 @@
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@ -1101,6 +1472,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
@ -1149,6 +1533,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -1230,6 +1637,19 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@ -1275,12 +1695,38 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -1290,6 +1736,16 @@
"node": ">=0.6"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@ -1313,6 +1769,13 @@
"node": ">= 0.6"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1337,6 +1800,22 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
@ -1359,6 +1838,24 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -2,10 +2,12 @@
"name": "servicenow-glpi-sync",
"version": "1.0.0",
"description": "A Node.js application for integrating ServiceNow and GLPI.",
"main": "app.js",
"main": "cron.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"start": "npm run start:prod",
"dev": "cross-env NODE_ENV=development nodemon cron.js",
"start:dev": "cross-env NODE_ENV=development node cron.js",
"start:prod": "cross-env NODE_ENV=production node cron.js",
"test": "echo \"No tests specified\" && exit 0"
},
"dependencies": {
@ -13,8 +15,14 @@
"dotenv": "^10.0.0",
"express": "^5.1.0",
"mysql2": "^3.14.5",
"node-cron": "^4.2.1",
"pg": "^8.7.1",
"winston": "^3.17.0"
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"cross-env": "^7.0.3",
"nodemon": "^3.1.4"
},
"author": "Your Name",
"license": "MIT"

View File

@ -1,370 +0,0 @@
[
{
"sys_id": "00ed542efbd4fe1023e5f8d65eefdc9b",
"sys_created_on": "2025-10-02 23:52:10",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "NOVO\n\nTESTE\n\nPARA \n\nVERIFICAR\n\nHTML\n\nNO \n\nCOMENTARIO\n\nTestando parágrafo\n\n**Testando negrito (B)**\n\n***Testando Italico (I)***\n\n> \r\n***Testando Blockquote***\n\nTestando DIV\n\nTestando alinhamento",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "05d54e5afb1cbe1023e5f8d65eefdc94",
"sys_created_on": "2025-10-02 12:47:52",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "068c5caafbd4fe1023e5f8d65eefdccf",
"sys_created_on": "2025-10-02 23:46:18",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Testando a inserção de imagens</p>\r\n<p><a href=\"/front/document.send.php?docid=82186&itemtype=Ticket&items_id=33776\" target=\"_blank\" ><img alt=\"cb4c1e7e-ba446868-68df0a7d462e59.15008476\" width=\"1197\" src=\"/front/document.send.php?docid=82186&itemtype=Ticket&items_id=33776\" /></a></p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "16954a5afb1cbe1023e5f8d65eefdcdf",
"sys_created_on": "2025-10-02 12:46:51",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "1b1dd182fbd8fe50ef1ffe316eefdc5f",
"sys_created_on": "2025-10-01 15:11:30",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "2a5d15c2fb187e1023e5f8d65eefdce3",
"sys_created_on": "2025-10-01 15:12:31",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "2bdd542efbd4fe1023e5f8d65eefdc1f",
"sys_created_on": "2025-10-02 23:52:08",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "Mais um teste para remoção de html no comentario. \n\nTestando parágrafo\n\n**Testando negrito (B)**\n\n***Testando Italico (I)***\n\n> \r\n***Testando Blockquote***\n\nTestando DIV\n\nTestando alinhamento",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "35954a96fb107290ef1ffe316eefdc85",
"sys_created_on": "2025-10-02 12:46:50",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "3d68102afbd87290ef1ffe316eefdc69",
"sys_created_on": "2025-10-02 23:28:15",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47141, content=&#60;p&#62;Testando a inserção de imagens&#60;/p&#62;\r\n&#60;p&#62;&#60;a href=\"/front/document.send.php?docid=82186&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68df0a7d462e59.15008476\" width=\"1197\" src=\"/front/document.send.php?docid=82186&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;, date=2025-10-02T23:27:59.000Z, date_creation=2025-10-02T23:27:59.000Z, date_mod=2025-10-02T23:27:59.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "3e8c9caafbd4fe1023e5f8d65eefdca1",
"sys_created_on": "2025-10-02 23:46:21",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Teste Comentario Limpo</p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "41d58e96fb107290ef1ffe316eefdc6a",
"sys_created_on": "2025-10-02 12:47:53",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "4a3b14eafbd87290ef1ffe316eefdcc1",
"sys_created_on": "2025-10-02 23:40:35",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47145, content=&#60;p&#62;NOVO&#60;/p&#62;\r\n&#60;p&#62;&#60;br&#62;&#60;br&#62;TESTE&#60;br&#62;&#60;br&#62;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;PARA&#38;nbsp;&#60;br&#62;&#60;br&#62;&#60;br&#62;&#60;br&#62;&#60;br&#62;VERIFICAR&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;HTML&#60;br&#62;&#60;br&#62;&#60;br&#62;NO&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;COMENTARIO&#60;br&#62;&#60;br&#62;&#60;br&#62;&#60;/p&#62;\r\n&#60;p&#62;Testando&#38;nbsp; parágrafo&#60;/p&#62;\r\n&#60;p&#62;&#60;strong&#62;Testando negrito (B)&#60;/strong&#62;&#60;/p&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Italico (I)&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;blockquote&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Blockquote&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;div&#62;Testando DIV&#60;/div&#62;\r\n&#60;div&#62;&#38;nbsp;&#60;/div&#62;\r\n&#60;div style=\"text-align: justify;\"&#62;Testando alinhamento&#60;/div&#62;\r\n&#60;/blockquote&#62;\r\n&#60;p&#62;&#60;br&#62;&#60;br&#62;&#60;br&#62;&#60;/p&#62;, date=2025-10-02T23:40:13.000Z, date_creation=2025-10-02T23:40:13.000Z, date_mod=2025-10-02T23:40:13.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "4b71142efb94fe1023e5f8d65eefdca6",
"sys_created_on": "2025-10-02 22:58:01",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47139, content=&#60;p&#62;Comentário teste, para validar funcionamento do Midware&#60;/p&#62;, date=2025-10-02T22:44:22.000Z, date_creation=2025-10-02T22:44:22.000Z, date_mod=2025-10-02T22:44:22.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "5070ca12fb107290ef1ffe316eefdc28",
"sys_created_on": "2025-10-02 12:24:19",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "Teste fora do código",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "598c5caafbd4fe1023e5f8d65eefdc43",
"sys_created_on": "2025-10-02 23:46:15",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Vai ter como sim, podemos ver</p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "5cb8e9cefb187e1023e5f8d65eefdcb0",
"sys_created_on": "2025-10-01 16:02:05",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "5f16ce5afb1cbe1023e5f8d65eefdcea",
"sys_created_on": "2025-10-02 12:49:07",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "6716829afb1cbe1023e5f8d65eefdc5e",
"sys_created_on": "2025-10-02 12:49:08",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "724825cefb187e1023e5f8d65eefdcd3",
"sys_created_on": "2025-10-01 16:00:20",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "75e3d242fbdcfe50ef1ffe316eefdc6c",
"sys_created_on": "2025-10-01 19:10:53",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "798c1c2efbd87290ef1ffe316eefdc3b",
"sys_created_on": "2025-10-02 23:46:17",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Mais um teste para remoção de html no comentario.&nbsp;</p>\r\n<p>Testando&nbsp; parágrafo</p>\r\n<p><strong>Testando negrito (B)</strong></p>\r\n<p><em><strong>Testando Italico (I)</strong></em></p>\r\n<blockquote>\r\n<p><em><strong>Testando Blockquote</strong></em></p>\r\n<p>&nbsp;</p>\r\n<div>Testando DIV</div>\r\n<div>&nbsp;</div>\r\n<div style=\"text-align: justify;\">Testando alinhamento</div>\r\n</blockquote>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "7bdd542efbd4fe1023e5f8d65eefdc5d",
"sys_created_on": "2025-10-02 23:52:09",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "Comentatradic\n\nzcx\n\nadsgvsdbf\n\nteste\n\nv\n\nszxc\n\na\n\na\n\na\n\na\n\na\n\na\n\na\n\na\n\nTestando parágrafo\n\n**Testando negrito (B)**\n\n***Testando Italico (I)***\n\n> \r\n***Testando Blockquote***\n\nTestando DIV\n\nTestando alinhamento",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "8568ad8efbd8fe50ef1ffe316eefdc7b",
"sys_created_on": "2025-10-01 16:00:46",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "952e98aefbd87290ef1ffe316eefdc69",
"sys_created_on": "2025-10-02 23:53:22",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "COMENTARIO TESTE\n\nTESTE\n\nCOMENTARIO TESTE\n\nTESTE COM IMAGEM:\n\nTestando a inserção de imagens\n\nTestando parágrafo\n\n**Testando negrito (B)**\n\n***Testando Italico (I)***\n\n> \r\n***Testando Blockquote***\n\nTestando DIV\n\nTestando alinhamento",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "9599546afbd87290ef1ffe316eefdcf8",
"sys_created_on": "2025-10-02 23:33:25",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47142, content=&#60;div class=\"rich_text_container\"&#62;\r\n&#60;p&#62;Mais um teste para remoção de html no comentario.&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;Testando&#38;nbsp; parágrafo&#60;/p&#62;\r\n&#60;p&#62;&#60;strong&#62;Testando negrito (B)&#60;/strong&#62;&#60;/p&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Italico (I)&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;blockquote&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Blockquote&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;div&#62;Testando DIV&#60;/div&#62;\r\n&#60;div&#62;&#38;nbsp;&#60;/div&#62;\r\n&#60;div style=\"text-align: justify;\"&#62;Testando alinhamento&#60;/div&#62;\r\n&#60;/blockquote&#62;\r\n&#60;/div&#62;, date=2025-10-02T23:33:11.000Z, date_creation=2025-10-02T23:33:11.000Z, date_mod=2025-10-02T23:33:11.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "9b4786dafb1cbe1023e5f8d65eefdc04",
"sys_created_on": "2025-10-02 12:54:19",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "02/10/2025 09:54:18 - SOTHIS CAOA (Comentários adicionais)\n02/10/2025 09:54:18 - SOTHIS CAOA (Comentários adicionais)\n&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;\n\n\n\n",
"sys_created_by": "admin.caoa",
"element": "comments"
},
{
"sys_id": "9f4786dafb1cbe1023e5f8d65eefdca2",
"sys_created_on": "2025-10-02 12:54:18",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "a25dd582fbd8fe50ef1ffe316eefdce8",
"sys_created_on": "2025-10-01 15:12:33",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "a98c1c2efbd87290ef1ffe316eefdc11",
"sys_created_on": "2025-10-02 23:46:17",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Comentário teste, para validar funcionamento do Midware</p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "ab47c25afb107290ef1ffe316eefdcc3",
"sys_created_on": "2025-10-02 12:54:19",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "ad2c3146fb987e1023e5f8d65eefdc72",
"sys_created_on": "2025-10-01 17:27:05",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "ad8c5caafbd4fe1023e5f8d65eefdc62",
"sys_created_on": "2025-10-02 23:46:16",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>fiz tudo, olha a prova ai:<br><br><a href=\"/front/document.send.php?docid=82185&itemtype=Ticket&items_id=33776\" target=\"_blank\" ><img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&itemtype=Ticket&items_id=33776\" /></a></p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "c027e54efbd8fe50ef1ffe316eefdc47",
"sys_created_on": "2025-10-01 15:55:14",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "c168ad8efbd8fe50ef1ffe316eefdca2",
"sys_created_on": "2025-10-01 16:00:47",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "c28c1c2efbd87290ef1ffe316eefdcb1",
"sys_created_on": "2025-10-02 23:46:19",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<div class=\"rich_text_container\">\r\n<p>Mais um teste para remoção de html no comentario.&nbsp;</p>\r\n<p>Testando&nbsp; parágrafo</p>\r\n<p><strong>Testando negrito (B)</strong></p>\r\n<p><em><strong>Testando Italico (I)</strong></em></p>\r\n<blockquote>\r\n<p><em><strong>Testando Blockquote</strong></em></p>\r\n<p>&nbsp;</p>\r\n<div>Testando DIV</div>\r\n<div>&nbsp;</div>\r\n<div style=\"text-align: justify;\">Testando alinhamento</div>\r\n</blockquote>\r\n</div>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "cbf32d8afb187e1023e5f8d65eefdc64",
"sys_created_on": "2025-10-01 15:41:32",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "cfe25202fbdcfe50ef1ffe316eefdc58",
"sys_created_on": "2025-10-01 19:06:36",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "d18ad4aafbd87290ef1ffe316eefdccc",
"sys_created_on": "2025-10-02 23:37:31",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47144, content=&#60;p&#62;Comentatradic&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;zcx&#60;/p&#62;\r\n&#60;p&#62;adsgvsdbf&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;teste&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;v&#60;/p&#62;\r\n&#60;p&#62;szxc&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;a&#60;/p&#62;\r\n&#60;p&#62;Testando&#38;nbsp; parágrafo&#60;/p&#62;\r\n&#60;p&#62;&#60;strong&#62;Testando negrito (B)&#60;/strong&#62;&#60;/p&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Italico (I)&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;blockquote&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Blockquote&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;div&#62;Testando DIV&#60;/div&#62;\r\n&#60;div&#62;&#38;nbsp;&#60;/div&#62;\r\n&#60;div style=\"text-align: justify;\"&#62;Testando alinhamento&#60;/div&#62;\r\n&#60;/blockquote&#62;, date=2025-10-02T23:37:16.000Z, date_creation=2025-10-02T23:37:16.000Z, date_mod=2025-10-02T23:37:16.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "d8b8e9cefb187e1023e5f8d65eefdca0",
"sys_created_on": "2025-10-01 16:02:05",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "d968d866fbd4fe1023e5f8d65eefdc46",
"sys_created_on": "2025-10-02 23:28:14",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "{id=47140, content=&#60;p&#62;Mais um teste para remoção de html no comentario.&#38;nbsp;&#60;/p&#62;\r\n&#60;p&#62;Testando&#38;nbsp; parágrafo&#60;/p&#62;\r\n&#60;p&#62;&#60;strong&#62;Testando negrito (B)&#60;/strong&#62;&#60;/p&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Italico (I)&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;blockquote&#62;\r\n&#60;p&#62;&#60;em&#62;&#60;strong&#62;Testando Blockquote&#60;/strong&#62;&#60;/em&#62;&#60;/p&#62;\r\n&#60;p&#62;&#38;nbsp;&#60;/p&#62;\r\n&#60;div&#62;Testando DIV&#60;/div&#62;\r\n&#60;div&#62;&#38;nbsp;&#60;/div&#62;\r\n&#60;div style=\"text-align: justify;\"&#62;Testando alinhamento&#60;/div&#62;\r\n&#60;/blockquote&#62;, date=2025-10-02T23:26:44.000Z, date_creation=2025-10-02T23:26:44.000Z, date_mod=2025-10-02T23:26:44.000Z}",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "e31dd182fbd8fe50ef1ffe316eefdc96",
"sys_created_on": "2025-10-01 15:11:31",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;fiz tudo, olha a prova ai:&#60;br&#62;&#60;br&#62;&#60;a href=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" target=\"_blank\" &#62;&#60;img alt=\"cb4c1e7e-ba446868-68d4315d901ec4.10948457\" width=\"1092\" src=\"/front/document.send.php?docid=82185&#38;itemtype=Ticket&#38;items_id=33776\" /&#62;&#60;/a&#62;&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "eaecfd46fb987e1023e5f8d65eefdcab",
"sys_created_on": "2025-10-01 17:30:27",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "ee8c5c2efbd87290ef1ffe316eefdc01",
"sys_created_on": "2025-10-02 23:46:20",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>NOVO</p>\r\n<p><br><br>TESTE<br><br></p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>PARA&nbsp;<br><br><br><br><br>VERIFICAR</p>\r\n<p>&nbsp;</p>\r\n<p>HTML<br><br><br>NO&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>COMENTARIO<br><br><br></p>\r\n<p>Testando&nbsp; parágrafo</p>\r\n<p><strong>Testando negrito (B)</strong></p>\r\n<p><em><strong>Testando Italico (I)</strong></em></p>\r\n<blockquote>\r\n<p><em><strong>Testando Blockquote</strong></em></p>\r\n<p>&nbsp;</p>\r\n<div>Testando DIV</div>\r\n<div>&nbsp;</div>\r\n<div style=\"text-align: justify;\">Testando alinhamento</div>\r\n</blockquote>\r\n<p><br><br><br></p>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "ee8c9caafbd4fe1023e5f8d65eefdc31",
"sys_created_on": "2025-10-02 23:46:19",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "<p>Comentatradic</p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>zcx</p>\r\n<p>adsgvsdbf</p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>&nbsp;</p>\r\n<p>teste</p>\r\n<p>&nbsp;</p>\r\n<p>v</p>\r\n<p>szxc</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>a</p>\r\n<p>Testando&nbsp; parágrafo</p>\r\n<p><strong>Testando negrito (B)</strong></p>\r\n<p><em><strong>Testando Italico (I)</strong></em></p>\r\n<blockquote>\r\n<p><em><strong>Testando Blockquote</strong></em></p>\r\n<p>&nbsp;</p>\r\n<div>Testando DIV</div>\r\n<div>&nbsp;</div>\r\n<div style=\"text-align: justify;\">Testando alinhamento</div>\r\n</blockquote>",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "fae25202fbdcfe50ef1ffe316eefdc2c",
"sys_created_on": "2025-10-01 19:06:34",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
},
{
"sys_id": "fde3decefbd87e1023e5f8d65eefdcc8",
"sys_created_on": "2025-10-01 19:10:51",
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
"value": "&#60;p&#62;Vai ter como sim, podemos ver&#60;/p&#62;",
"sys_created_by": "SOTHIS.CAOA",
"element": "comments"
}
]

View File

@ -1,27 +1,49 @@
// src/app.js
require('dotenv').config();
const path = require('path');
const dotenv = require('dotenv');
const nodeEnv = process.env.NODE_ENV;
let envFile = '.env.development';
if (nodeEnv === 'production') {
envFile = '.env.production';
} else {
envFile = '.env.development';
}
dotenv.config({ path: path.resolve(__dirname, '..', envFile) });
const { processTicketsController } = require('./controllers/processTicketsController');
const { processCommentsController } = require('./controllers/processCommentsController');
const { processErrorController } = require('./controllers/processErrorController');
const { processStatusAndClosureController } = require('./controllers/processStatusController');
const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController');
const { logInfo } = require('./utils/logger');
logInfo('Aplicação iniciada', {
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
logInfo('Aplicacao iniciada', {
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
async function main() {
try {
await processErrorController(); // Reseta os erros
await processTicketsController(); // Coleta tickets do SN e cria no GLPI
await processCommentsController(); // Sincroniza chamados Bidirecionalmente
await processStatusAndClosureController(); // Fecha chamados Bidirecionalmente
logInfo('🎉 Ciclo de sincronização concluído com sucesso! 🎉');
} catch (error) {
logInfo('Erro na aplicação:', error);
}
const ticketLifecycleEnabled = process.env.ENABLE_GLPI_CLOSE_CRON !== 'false';
await processErrorController();
await processTicketsController();
if (ticketLifecycleEnabled) {
await processTicketLifecycleController();
} else {
logInfo('Motor de regras de status/fechamento desabilitado (ENABLE_GLPI_CLOSE_CRON=false).');
}
await processCommentsController();
logInfo('Ciclo de sincronizacao concluido com sucesso.');
} catch (error) {
logInfo('Erro na aplicacao:', error);
}
}
main();
module.exports = { main };

View File

@ -1,20 +1,33 @@
const {fetchCommentsFromServiceNow } = require('../services/servicenowService');
const { fetchCommentsFromServiceNow } = require('../services/servicenowService');
const { syncCommentsGlpitoSN, syncCommentsSNtoGlpi } = require('../services/glpiCommentService');
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const { logDebug } = require('../utils/logger');
const processCommentsController = async () => {
// Usa getTicketsToMonitor para incluir tickets 'synced' e 'solved'.
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
logDebug(`Tickets em monitoramento: ${ticketsToMonitor.length}`);
for (const ticket of ticketsToMonitor) {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === 5) {
// Regra de negocio: enquanto o GLPI estiver Solucionado, nao manda comentario novo
// do SN pra dentro do chamado. A decisao de reabertura acontece em
// processTicketLifecycleController.js, que roda antes deste controlador no ciclo.
logDebug(`Ticket ${ticket.glpi_ticket_id} permanece Solucionado no GLPI. Comentarios SN -> GLPI bloqueados ate reabertura.`);
await syncCommentsGlpitoSN(ticket);
continue;
}
for (const ticket of ticketsToMonitor){
const comments = await fetchCommentsFromServiceNow(ticket);
if(comments.length != 0){await syncCommentsSNtoGlpi(comments, ticket);}
if (comments.length !== 0) {
await syncCommentsSNtoGlpi(comments, ticket);
}
await syncCommentsGlpitoSN(ticket);
}
};
}
module.exports = {
processCommentsController
}
};

View File

@ -1,40 +1,44 @@
// src/controllers/processErrorController.js
const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { logInfo, logError, logWarning, logDebug } = require('../utils/logger');
const MAX_ERROR_RETRIES = Number(process.env.MAX_ERROR_RETRIES || 5);
const processErrorController = async () => {
logInfo('Iniciando verificação de tickets com erro...');
logDebug('Iniciando verificação de tickets com erro...');
try {
const errorTickets = await TicketSyncModel.getTicketsInErrorState();
if (errorTickets.length === 0) {
logInfo('Nenhum ticket com erro encontrado para reprocessamento.');
logDebug('Nenhum ticket com erro encontrado para reprocessamento.');
return;
}
logWarning(`Encontrados ${errorTickets.length} tickets com erro. Tentando reprocessar...`);
for (const ticket of errorTickets) {
const retryCount = await TicketSyncModel.bumpErrorRetryCount(ticket.sn_ticket_id);
if (retryCount > MAX_ERROR_RETRIES) {
await TicketSyncModel.markPermanentError(ticket.sn_ticket_id);
logWarning(`Ticket SN ID ${ticket.sn_ticket_id} excedeu ${MAX_ERROR_RETRIES} tentativas de reprocessamento. Marcado como 'error_permanent' (requer intervencao manual).`);
continue;
}
let newSnStatus = ticket.sn_sync_status;
let newGlpiStatus = ticket.glpi_sync_status;
// Reset 'error' na criação do GLPI para tentar de novo
if (ticket.glpi_sync_status === 'error') {
newGlpiStatus = 'pending_check';
}
// Reset 'error_sync' para tentar sincronizar status/comentários de novo
if (ticket.sn_sync_status === 'error_sync') newSnStatus = 'synced';
if (ticket.glpi_sync_status === 'error_sync') newGlpiStatus = 'synced';
// Reset 'error_closing' para tentar fechar de novo
if (ticket.sn_sync_status === 'error_closing') newSnStatus = 'pending_close';
if (ticket.glpi_sync_status === 'error_closing') newGlpiStatus = 'pending_close';
// Atualiza o status para que o ticket reentre no fluxo normal
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, newGlpiStatus, newSnStatus);
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`);
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}, tentativa ${retryCount}/${MAX_ERROR_RETRIES}).`);
}
} catch (error) {
@ -45,3 +49,11 @@ const processErrorController = async () => {
module.exports = {
processErrorController
};
/**
* @file processErrorController.js
* @description Este controlador é responsável por gerenciar tickets que entraram em estado de erro durante o processo de sincronização.
* Ele busca todos os tickets marcados com status de erro (ex: 'error', 'error_sync') e os reseta para um estado anterior válido
* (ex: 'pending_check', 'synced'). Isso permite que os tickets sejam automaticamente reprocessados na próxima execução do ciclo de sincronização,
* aumentando a resiliência do sistema.
*/

View File

@ -1,155 +0,0 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { stripHTML } = require('../utils/commentSanitizer');
const { closeTicketInServiceNow, updateStatusInServiceNow, addWorkNoteToServiceNow } = require('../services/servicenowService');
const { closeTicketInGlpi } = require('../services/glpiTicketService');
const processStatusAndClosureController = async () => {
logInfo('Iniciando processamento de status e fechamento de tickets...');
try {
// --- FASE 1: Sincronização de Status para Tickets Abertos (Bidirecional) ---
logInfo('Iniciando verificação e sincronização de status...');
// Busca todos os tickets que não estão permanentemente fechados.
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
if (ticketsToMonitor.length === 0) {
logInfo('Nenhum ticket sincronizado (aberto) encontrado para verificação de status.');
} else {
logInfo(`Encontrados ${ticketsToMonitor.length} tickets para monitorar status (synced/solved).`);
}
for (const ticket of ticketsToMonitor) {
try {
// Obter detalhes completos do ticket SN para ter sys_id, tipo e status atual
const ticketSnDetails = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSnDetails) {
logWarning(`Detalhes do ticket SN ${ticket.sn_ticket_id} não encontrados. Pulando.`);
continue;
}
const snCurrentStatus = ticketSnDetails.status;
// ================= LÓGICA DE DETECÇÃO DE MUDANÇA NO GLPI =================
// Primeiro, verificamos se o GLPI mudou de estado por conta própria.
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
// Converte o status numérico do GLPI para a string correspondente no ServiceNow (ex: 'Em Atendimento')
const glpiLiveStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiLiveStatus);
// Compara o status real do GLPI com o status que temos do ServiceNow
if (glpiLiveStatus !== null && glpiLiveStatusAsString !== snCurrentStatus) {
logInfo(`Mudança de status detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. GLPI está como '${glpiLiveStatusAsString}', SN está como '${snCurrentStatus}'.`);
// Se uma mudança foi detectada no GLPI, forçamos o "bastão" para ele.
ticket.source_last = 'GLPI';
}
// ================= LÓGICA SNOW -> GLPI =================
// Se a última atualização veio do ServiceNow, atualiza o GLPI
if (ticket.source_last === 'SNOW') {
logInfo(`SN ${ticketSnDetails.ticket_number} (SNOW -> GLPI): Verificando status '${snCurrentStatus}'`);
// **LÓGICA DE REABERTURA REFINADA**
// Busca o status atual do GLPI para tomar uma decisão inteligente.
const glpiCurrentStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
// Se o ticket no GLPI está permanentemente Fechado (6), não fazemos nada.
if (glpiCurrentStatus === 6) {
logWarning(`Ticket GLPI ${ticket.glpi_ticket_id} está permanentemente Fechado (6). Ação de reabertura pelo SN ('${snCurrentStatus}') foi bloqueada.`);
} else if (snCurrentStatus === 'Resolvido' && glpiCurrentStatus !== 5) { // SN foi para 'Resolved' e GLPI ainda não está 'Solucionado'
logInfo(`SN ${ticketSnDetails.ticket_number} foi Resolvido. Sincronizando para GLPI...`);
await closeTicketInGlpi(ticket);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved');
} else if (snCurrentStatus === 'Resolvido' && glpiCurrentStatus === 5) {
// Ambos já estão resolvidos, nenhuma ação necessária.
logInfo(`SN ${ticketSnDetails.ticket_number} e GLPI ${ticket.glpi_ticket_id} já estão no estado Resolvido/Solucionado. Nenhuma ação necessária.`);
} else if (snCurrentStatus === 'Encerrado - Omitido' || snCurrentStatus === 'Encerrado') { // SN foi para 'Closed'
logInfo(`SN ${ticketSnDetails.ticket_number} foi Fechado. Sincronizando para GLPI...`);
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'closed');
} else {
// Se estiver Solucionado (5) ou em qualquer outro estado, permite a atualização (reabertura).
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, snCurrentStatus);
logInfo(`Status do ticket GLPI ${ticket.glpi_ticket_id} atualizado para '${snCurrentStatus}'.`);
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI'); // Passa o bastão
}
}
// ================= LÓGICA GLPI -> SNOW =================
// Se a última atualização veio do GLPI, atualiza o ServiceNow
else if (ticket.source_last === 'GLPI') {
logInfo(`SN ${ticketSnDetails.ticket_number} (GLPI -> SNOW): Verificando status no GLPI.`);
const glpiCurrentStatus = glpiLiveStatus; // Reutiliza o status que já buscamos
if (glpiCurrentStatus !== null) {
if (glpiCurrentStatus === 5) { // GLPI foi para 'Solucionado'
// Apenas processa se o SN não estiver já resolvido/fechado
if (ticket.sn_sync_status !== 'solved' && ticket.sn_sync_status !== 'closed') {
logInfo(`GLPI ${ticket.glpi_ticket_id} foi Solucionado. Sincronizando para SN...`);
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
// REGRA "FORA DO ESCOPO"
if (solution?.solutiontypes_id === 27) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
} else {
// REGRA DE NEGÓCIO: Não pode fechar um ticket que está "Em Espera" ou "Aguardando Atendimento" no SN.
if (snCurrentStatus === 'Em Espera' || snCurrentStatus === 'Aguardando Atendimento') {
logWarning(`Tentativa de fechar o ticket SN ${ticketSnDetails.ticket_number} que está '${snCurrentStatus}'. Ação bloqueada.`);
// Não Adiciona uma nota no SN e reabre o ticket no GLPI para refletir o status do SN.
//await addWorkNoteToServiceNow(ticket.sn_ticket_id, `O ticket correspondente no GLPI (${ticket.glpi_ticket_id}) foi solucionado, mas o chamado no ServiceNow está '${snCurrentStatus}'. É necessário retomar o atendimento para prosseguir com o encerramento.`);
//Atualiza o status do GLPI
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, snCurrentStatus); // Reabre o GLPI imediatamente
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW'); // Passa o bastão para o SN
continue; // Pula para o próximo ticket
}
// FLUXO NORMAL DE RESOLUÇÃO
const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integração GLPI.';
const resolvedAt = solution?.date_mod || new Date();
await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'solved', 'solved');
}
}
} else if (glpiCurrentStatus === 6) {
logInfo(`GLPI ${ticket.glpi_ticket_id} foi Fechado. Sincronizando para SN...`);
// Apenas marca nosso banco como fechado, pois o SN já deve estar fechado ou será fechado por automação interna.
if (ticket.glpi_sync_status !== 'closed') {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status);
}
} else {
// Se o status for de um ticket aberto (reabertura, etc.), propaga a mudança.
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiCurrentStatus);
if (glpiStatusAsString !== snCurrentStatus) {
logInfo(`Diferença de status detectada (GLPI: ${glpiStatusAsString}, SN: ${snCurrentStatus}). Sincronizando...`);
await updateStatusInServiceNow(ticket.sn_ticket_id, glpiStatusAsString);
}
}
// Passa o bastão de volta para o SN se a ação não for de finalização
if (glpiCurrentStatus < 5) {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
}
} else {
logWarning(`Não foi possível obter o status atual do ticket GLPI ${ticket.glpi_ticket_id}.`);
}
}
// Se source_last não está definido ou é desconhecido, podemos definir um padrão ou logar um aviso.
// Por enquanto, vamos apenas logar.
else {
logWarning(`source_last desconhecido para o ticket SN ${ticketSnDetails.ticket_number}. Pulando sincronização de status.`);
}
} catch (error) {
logError(error, `Erro ao sincronizar status do ticket SN ${ticket.sn_ticket_id} (GLPI ID: ${ticket.glpi_ticket_id}).`);
// Marcar o ticket como erro para revisão manual
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error_sync', 'error_sync');
}
}
} catch (error) {
logError(error, 'Erro geral no processamento de status e fechamento de tickets.');
}
};
module.exports = {
processStatusAndClosureController
};

View File

@ -2,70 +2,63 @@ const TicketSnModel = require('../models/ticketSnModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const SyncControlModel = require('../models/syncControlModel');
const { fetchTicketsFromServiceNow: fetchTicketsApi, fetchRequestsFromServiceNow: fetchRequestsApi, fetchScItemOptionValue } = require('../services/servicenowService');
const { logInfo, logError, logSync } = require('../utils/logger');
const e = require('express');
const { logInfo, logError, logSync, logDebug } = require('../utils/logger');
/**
* Processa e salva um lote de tickets (incidentes ou requisições).
* Processa e salva um lote de tickets (incidentes ou requisicoes).
* @param {Array} tickets - A lista de tickets da API do ServiceNow.
* @param {('incidente'|'requisicao')} type - O tipo de ticket.
*/
const processAndSaveTickets = async (tickets, type) => {
// Helper para converter a data do formato 'DD/MM/YYYY HH:mm:ss' para um objeto Date.
const parseServiceNowDate = (dateString) => {
if (!dateString) return null;
// Verifica se já está em um formato reconhecível (como ISO 8601)
if (!dateString.includes('/')) {
return new Date(dateString);
}
const parts = dateString.match(/(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2}):(\d{2})/);
if (!parts) return null; // Retorna nulo se o formato for inesperado
// Formato: new Date(ano, mês-1, dia, hora, minuto, segundo)
// Formato: new Date(ano, mes-1, dia, hora, minuto, segundo)
return new Date(parts[3], parts[2] - 1, parts[1], parts[4], parts[5], parts[6]);
};
if (!tickets || tickets.length === 0) {
logInfo(`Nenhum ticket do tipo '${type}' para processar.`);
return ''; // Retorna string vazia se não houver tickets
logDebug(`Nenhum ticket do tipo '${type}' para processar.`);
return '';
}
logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
let latestUpdateTimestamp = new Date(0); // Inicia com a data mínima (epoch)
logInfo(`INFO: Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
let latestUpdateTimestamp = new Date(0);
for (const ticket of tickets) {
try {
// --- LÓGICA DE ENRIQUECIMENTO DA DESCRIÇÃO ---
// Se for uma requisição e a descrição estiver vazia, busca na sc_item_option.
if (type === 'requisicao' && (!ticket.description || ticket.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.number}. Buscando em sc_item_option...`);
// O nome 'justificativa' é um exemplo, use o nome real da sua variável.
if (type === 'requisicao') {
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
logInfo(`Buscando variaveis de catalogo para requisicao ${ticketNumber}.`);
const extraData = await fetchScItemOptionValue(ticket.sys_id, 'justificativa');
const vars = {};
(extraData || []).forEach(item => {
const question = item['sc_item_option.item_option_new.question_text'];
const value = item['sc_item_option.value'];
if (question === 'Select what you want') vars.whatYouWant = value;
if (question === 'Please justify the need') vars.justification = value;
if (question === 'Telephone') vars.telephone = value;
(extraData || []).forEach((item) => {
const question = item.question_text;
const name = item.variable_name;
const value = item.value;
if (name === 'select_what_you_want' || question === 'Select what you want') vars.whatYouWant = value;
if (name === 'please_justify_the_need' || question === 'Please justify the need') vars.justification = value;
if (name === 'telephone_favorecido_ti' || question === 'Telephone') vars.telephone = value;
});
if (vars.justification) {
ticket.description = vars.justification; // Atualiza o objeto do ticket em memória
logInfo(`Descrição da requisição ${ticket.number} atualizada com valor de sc_item_option.`);
}
if (vars.telephone){
ticket.justificativa = vars.justification || null;
if (vars.telephone) {
ticket.telefone = vars.telephone;
logInfo(`Telefone da requisição ${ticket.number} atualizado com valor de sc_item_option.`);
}
logInfo(`Variaveis da requisicao ${ticketNumber}: justificativa=${ticket.justificativa ? 'ok' : 'vazia'}, telefone=${ticket.telefone ? 'ok' : 'vazio'}.`);
}
// --- FIM DA LÓGICA DE ENRIQUECIMENTO ---
// 1. Salva o ticket na tabela 'tickets_sn'
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
const existingTicketSn = await TicketSnModel.findByTicketNumber(ticketNumber);
const oldStatus = existingTicketSn?.status || null;
const { id: idTableTicketSn, wasInserted } = await TicketSnModel.saveTicket(ticket, type);
// 2. Prepara os dados para a tabela de sincronização 'ticket_sync'
// Para requisições, o status está diretamente em ticket.state
const ticketStatus = ticket.state;
const dataSync = {
@ -76,31 +69,29 @@ const processAndSaveTickets = async (tickets, type) => {
updated_at: new Date()
};
// 3. Define o status inicial de sincronização
if (ticketStatus === 'Encerrado' || ticketStatus === 'Encerrado - Omitido') {
dataSync.sn_sync_status = 'closed';
dataSync.glpi_sync_status = 'ignored'; // Ignora no GLPI se já veio fechado do SN
dataSync.glpi_sync_status = 'ignored';
} else {
dataSync.sn_sync_status = 'collected'; // Status que indica que foi coletado do SN
dataSync.glpi_sync_status = 'pending_check'; // Status que indica que precisa ser checkado no GLPI
dataSync.sn_sync_status = 'collected';
dataSync.glpi_sync_status = 'pending_check';
}
dataSync.source_last = 'SNOW'; // A última atualização veio do ServiceNow
dataSync.source_last = 'SNOW';
// 4. Cria o registro de sincronização se ele não existir
const exists = await TicketSyncModel.getIdSyncBySnId(idTableTicketSn);
if (!exists) {
await TicketSyncModel.createSyncRecord(dataSync);
} else if (wasInserted === false) {
// Se o ticket foi atualizado (não inserido), significa que uma mudança veio do SN.
// Então, definimos o SN como a última fonte da verdade.
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. 'source_last' definido como 'SNOW'.`);
if (oldStatus !== ticketStatus) {
await TicketSyncModel.updateSourceLast(idTableTicketSn, 'SNOW');
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow (status '${oldStatus}' -> '${ticketStatus}'). 'source_last' definido como 'SNOW'.`);
} else {
logInfo(`Ticket ${ticket.number} recebido de novo pela margem de seguranca do watermark, sem mudanca de status ('${ticketStatus}'). Ignorando reset de 'source_last'.`);
}
} else {
logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`);
logDebug(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
}
// Se já existe, não faz nada, pois a lógica de atualização de status será em outro processo.
// Guarda o timestamp mais recente do lote para usar como a próxima marca d'água
const currentUpdateDate = parseServiceNowDate(ticket.sys_updated_on);
if (currentUpdateDate && currentUpdateDate.getTime() > latestUpdateTimestamp.getTime()) {
latestUpdateTimestamp = currentUpdateDate;
@ -112,49 +103,57 @@ const processAndSaveTickets = async (tickets, type) => {
}
logSync('ServiceNow', tickets.length, `${type}s`);
// Se encontramos uma data válida, a formata para 'YYYY-MM-DD HH:MM:SS'. Senão, retorna string vazia.
return latestUpdateTimestamp.getTime() > 0
? latestUpdateTimestamp.toISOString().slice(0, 19).replace('T', ' ')
: '';
};
/**
* Controller para buscar tickets e requisições do ServiceNow e salvá-los localmente.
* Controller para buscar tickets e requisicoes do ServiceNow e salva-los localmente.
*/
const processSyncController = async () => {
try {
// 1. Pega a marca d'água da última execução bem-sucedida
const watermark = await SyncControlModel.getWatermark('servicenow');
logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
// Busca incidentes
logDebug(`Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
const incidents = await fetchTicketsApi(watermark);
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
// Busca requisições
logInfo('📡 Buscando requisições do ServiceNow...');
logDebug('Buscando requisicoes do ServiceNow...');
const requests = await fetchRequestsApi(watermark);
const latestRequestUpdate = await processAndSaveTickets(requests, 'requisicao');
// 2. Determina a nova marca d'água (o timestamp mais recente entre todos os tickets processados)
const newWatermark = latestIncidentUpdate > latestRequestUpdate ? latestIncidentUpdate : latestRequestUpdate;
// 3. Se processamos algum ticket, atualiza a marca d'água para a próxima execução
if (newWatermark) {
// Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais
// Mantem margem de seguranca de 6 horas para evitar perda de eventos por atraso de replicacao, timezone ou clock skew entre sistemas.
const sixHoursInMillis = 6 * 60 * 60 * 1000;
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
await SyncControlModel.setWatermark('servicenow', nextWatermark);
} else {
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada.");
logDebug("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
}
} catch (error) {
logError(error, 'Erro no processo de sincronização de tickets do ServiceNow.');
logError(error, 'Erro no processo de sincronizacao de tickets do ServiceNow.');
}
};
module.exports = {
processSyncController
};
/**
* @file processSyncController.js
* @description
* Este controlador e responsavel por buscar novos tickets e atualizacoes do ServiceNow.
* Ele utiliza uma "marca d'agua" (watermark) para buscar apenas os registros modificados desde a ultima execucao,
* otimizando o processo.
*
* Funcionalidades:
* - Busca incidentes e requisicoes do ServiceNow com base na data da ultima atualizacao.
* - Para cada ticket, salva ou atualiza o registro no banco de dados intermediario (`tickets_sn`).
* - Cria ou atualiza o registro de controle na tabela `ticket_sync`, definindo o status inicial e a origem da atualizacao (`source_last`).
* - Implementa uma logica de enriquecimento de dados para requisicoes com descricao vazia, buscando informacoes de variaveis de catalogo (`sc_item_option`).
* - Ao final, atualiza a marca d'agua para a proxima execucao, apenas se houverem tickets atualizados na execucao vigente.
*/

View File

@ -0,0 +1,219 @@
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSnModel = require('../models/ticketSnModel');
const {
addCommentToServiceNow,
updateStatusInServiceNow,
fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
closeTicketInServiceNow,
reassignTicketInServiceNow
} = require('../services/servicenowService');
const { isOutOfScopeSolution, handleOutOfScopeResolution, addServiceNowClosureNoteToGlpi } = require('../services/glpiTicketService');
const { stripHTML } = require('../utils/commentSanitizer');
const { logInfo, logError, logWarning, logDebug } = require('../utils/logger');
const DEFINITIVE_CLOSE_NOTICE = 'Chamado encerrado definitivamente. Tempo para reabertura encerrado. Caso necessario, por favor abra um novo Ticket.';
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
const MANDANTE = (process.env.MANDANTE || '').toUpperCase();
const isSnFinal = (liveSnStatus) => liveSnStatus === 'Resolvido' || liveSnStatus === 'Encerrado' || liveSnStatus === 'Encerrado - Omitido';
/**
* Regras 19-22 (mandante): fora dos status fixos (5, 6, fora de escopo), quando MANDANTE esta
* configurado, espelha o status intermediario (Aguardando Atendimento/Em Atendimento/Em Espera)
* do sistema mandante pro outro. So o mandante escreve - nunca le o status do lado seguidor pra
* decidir nada, entao nao ha risco de ping-pong. Sem MANDANTE configurado (ou valor invalido),
* comportamento identico a antes (regra de contencao: nada e espelhado).
*/
const applyMandanteMirroring = async (ticket, ticketSn, glpiStatus, liveSnStatus) => {
if (MANDANTE === 'GLPI') {
const desiredSnStatus = TicketGlpiModel.mapGlpiStatusToString(glpiStatus);
if (desiredSnStatus === 'Desconhecido') {
logWarning(`Mandante=GLPI: status GLPI ${glpiStatus} sem mapeamento para o SN. Pulando espelhamento para o ticket ${ticket.glpi_ticket_id}.`);
return;
}
if (desiredSnStatus !== liveSnStatus) {
const updated = await updateStatusInServiceNow(ticket.sn_ticket_id, desiredSnStatus);
if (!updated) {
logWarning(`Mandante=GLPI: falha ao espelhar status '${desiredSnStatus}' no SN ${ticketSn.ticket_number}. Tentando novamente no proximo ciclo.`);
return;
}
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI');
logInfo(`Mandante=GLPI: status do SN ${ticketSn.ticket_number} espelhado para '${desiredSnStatus}'.`);
}
} else if (MANDANTE === 'SNOW') {
const desiredGlpiStatus = TicketGlpiModel.mapStatus(liveSnStatus);
if (glpiStatus !== desiredGlpiStatus) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, liveSnStatus);
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
logInfo(`Mandante=SNOW: status do GLPI ${ticket.glpi_ticket_id} espelhado para '${liveSnStatus}'.`);
}
}
};
/**
* Regra 16/17 (Frente 2): quando o SN resolve/encerra um chamado que a Sothis ainda estava
* atendendo, avisa no proprio SN que a Sothis nao vai mais atender e transfere pra fila CAOA.
* So roda uma vez por ticket (chamado pelo caller apenas quando a nota pro GLPI e nova).
*/
const notifyAndReassignInServiceNow = async (ticket) => {
try {
const latestGlpiUpdate = await TicketUpdateModel.getLatestUpdate(ticket.id, 'glpi');
const glpiContext = latestGlpiUpdate?.content || null;
const baseMessage = 'Este chamado nao sera mais atendido pela equipe Sothis, pois foi encerrado pelo ServiceNow. Caso ainda precise de suporte, por favor abra um novo chamado.';
const message = glpiContext
? `${baseMessage}\n\nUltima atualizacao registrada pela Sothis:\n${glpiContext}`
: baseMessage;
await addCommentToServiceNow(ticket.sn_ticket_id, message);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
logInfo(`Ticket SN ${ticket.sn_ticket_id} avisado e transferido para fila CAOA (encerramento veio do proprio SN).`);
} catch (error) {
logError(error, `Falha ao notificar/reatribuir no SN para o ticket ${ticket.sn_ticket_id}`);
}
};
/**
* Motor de regras unico para status/fechamento/reabertura (regras fixas 10-18 do inventario de
* negocio). Substitui processGlpiClosureController.js + processStatusController.js: nao ha mais
* dois controladores decidindo o mesmo ticket no mesmo ciclo, e nenhuma decisao aqui depende de
* `source_last` (ele pode continuar sendo escrito so como rastro de auditoria).
*/
const processTicketLifecycleController = async () => {
try {
const tickets = await TicketSyncModel.getTicketsForClosureMonitor();
if (!tickets.length) {
logDebug('Nenhum ticket ativo para monitoramento de status/fechamento.');
return;
}
for (const ticket of tickets) {
try {
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
if (glpiStatus === null) {
logWarning(`Nao foi possivel obter o status atual do ticket GLPI ${ticket.glpi_ticket_id}.`);
continue;
}
const ticketSn = await TicketSnModel.findById(ticket.sn_ticket_id);
if (!ticketSn) {
continue;
}
const liveSnStatus = await fetchTicketLiveStatusFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const snIsFinal = isSnFinal(liveSnStatus);
// ================= GLPI = 5 (Solucionado) =================
if (glpiStatus === 5) {
if (ticket.sn_sync_status === 'solved') {
// Ja fechamos este ticket antes. So resta checar se foi reaberto pelo SN.
if (!snIsFinal) {
await TicketGlpiModel.updateStatus(ticket.glpi_ticket_id, 'Em Atendimento');
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
logInfo(`Reabertura detectada no SN para ticket ${ticket.sn_ticket_id}. GLPI ${ticket.glpi_ticket_id} retornou para Em Atendimento.`);
}
continue;
}
if (snIsFinal) {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} ja estava refletido no SN como '${liveSnStatus}'.`);
continue;
}
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
if (isOutOfScopeSolution(solution)) {
await handleOutOfScopeResolution(ticket, solution);
continue;
}
if (liveSnStatus === 'Em Espera' || liveSnStatus === 'Aguardando Atendimento') {
await updateStatusInServiceNow(ticket.sn_ticket_id, 'Em Atendimento');
}
const closeNotes = solution?.content ? stripHTML(solution.content) : 'Resolvido via integracao GLPI.';
const resolvedAt = solution?.date_mod || new Date();
const closeOk = await closeTicketInServiceNow(ticket.sn_ticket_id, closeNotes, resolvedAt);
if (!closeOk) {
logWarning(`Fechamento do ticket SN ${ticketSn.ticket_number} falhou. Mantendo GLPI ${ticket.glpi_ticket_id} para nova tentativa.`);
continue;
}
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'solved', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} solucionado e refletido no SN.`);
continue;
}
// ================= GLPI = 6 (Fechado definitivamente) =================
if (glpiStatus === 6) {
const sourceId = `glpi_closed_notice:${ticket.glpi_ticket_id}`;
const alreadyNotified = await TicketUpdateModel.getBySourceId(sourceId);
if (!alreadyNotified) {
const commentSent = await addCommentToServiceNow(ticket.sn_ticket_id, DEFINITIVE_CLOSE_NOTICE);
if (commentSent) {
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
update_type: 'close_notice',
source_system: 'glpi',
content: DEFINITIVE_CLOSE_NOTICE,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: 'done'
});
}
}
// Regra: nao altera status no SN ao fechar definitivamente no GLPI.
// Apenas registra comentario e remove do monitoramento local.
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'closed', 'closed', 'GLPI');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} encerrado definitivamente e refletido no SN.`);
continue;
}
// ================= GLPI < 5 (ainda aberto) =================
if (glpiStatus < 5) {
if (snIsFinal && ticket.glpi_sync_status !== 'solved' && ticket.glpi_sync_status !== 'closed') {
// SN pode ter resolvido/encerrado sozinho
const finalStatus = liveSnStatus === 'Resolvido' ? 'solucionado' : 'encerrado';
const collaboratorName = await fetchTicketCloserFromServiceNow(ticketSn.sys_id, ticketSn.tipo);
const noteResult = await addServiceNowClosureNoteToGlpi(ticket, collaboratorName, finalStatus);
if (noteResult.ok) {
if (noteResult.isNew) {
await notifyAndReassignInServiceNow(ticket);
}
const newStatus = finalStatus === 'solucionado' ? 'solved' : 'closed';
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, newStatus, newStatus, 'SNOW');
logInfo(`SN ${ticketSn.ticket_number} foi ${finalStatus === 'solucionado' ? 'resolvido' : 'encerrado'}. Nota registrada no GLPI ${ticket.glpi_ticket_id} sem fechar o chamado.`);
}
} else if (!snIsFinal) {
// Os dois lados estao em status intermediario - so mexe se houver mandante configurado
await applyMandanteMirroring(ticket, ticketSn, glpiStatus, liveSnStatus);
}
}
} catch (error) {
logError(error, `Falha ao processar ciclo de vida do ticket GLPI ${ticket.glpi_ticket_id}`);
}
}
} catch (error) {
logError(error, 'Erro geral no motor de regras de status/fechamento.');
}
};
module.exports = {
processTicketLifecycleController
};
/**
* @file processTicketLifecycleController.js
* @description Motor de regras unico para o ciclo de vida do ticket (regras fixas 10-18):
* fechamento GLPI->SN (status 5 e 6), reabertura (SN reabre enquanto GLPI ainda mostra
* solucionado), nota SN->GLPI quando o SN resolve/encerra por conta propria, e a regra de
* "fora de escopo". Substitui processGlpiClosureController.js e processStatusController.js -
* nao ha mais dois controladores competindo pelo mesmo ticket, e nenhuma decisao aqui depende de
* `source_last`. Por cima das regras fixas, `applyMandanteMirroring` implementa as regras 19-22
* (mandante) - espelhamento opcional de status intermediario, configuravel via env `MANDANTE`.
*/

View File

@ -1,19 +1,22 @@
// src/controllers/glpiController.js
const { syncTicketsToGlpi } = require('../services/glpiTicketService');
const { processSyncController } = require('./processSyncController');
const { processPendingExternalTicketLinks } = require('../services/servicenowService');
const processTicketsController = async () => {
// 1. Busca Incidentes e Requisições do Service Now e salva no banco intermediário
// Esta etapa prepara os dados para a sincronização com o GLPI.
await processSyncController();
//Sincroniza os tickets com o GLPI
await syncTicketsToGlpi();
await processPendingExternalTicketLinks();
}
module.exports = {
processTicketsController
}
/**
* @file processTicketsController.js
* @description Este controlador gerencia o fluxo inicial de criação de tickets.
* Ele primeiro invoca o `processSyncController` para buscar e salvar localmente os tickets do ServiceNow.
* Em seguida, chama o serviço `syncTicketsToGlpi` para processar esses tickets locais e criá-los no GLPI,
* estabelecendo a ligação inicial entre os sistemas.
*/

View File

@ -1,23 +1,29 @@
// src/data/database.js
// Configuração da conexão com o banco de dados PostgreSQL
const { Pool } = require('pg');
const { logInfo, logError, logDebug } = require('../utils/logger');
const pool = new Pool({
host: '10.0.120.75',
port: 5432,
database: 'snglpi',
user: 'desenvolvimento',
password: 'Ut@2S@$M9Xs@@W'
});
const poolConfig = {
host: process.env.SNGLPI_DB_HOST ,
port: process.env.SNGLPI_DB_PORT || 5432,
database: process.env.SNGLPI_DB_NAME,
user: process.env.SNGLPI_DB_USER,
password: process.env.SNGLPI_DB_PASSWORD
};
const pool = new Pool(poolConfig);
// Log da configuração do pool para depuração (sem a senha)
const sanitizedConfig = { ...poolConfig, password: '*****' };
logInfo('Configuração do Pool PostgreSQL (Banco Intermediário)', sanitizedConfig);
// testa a conexao
pool.on('connect', () => {
console.log('Conectado ao PostgreSQL');
logDebug('Conectado ao PostgreSQL');
});
pool.on('error', (err) => {
console.error('Erro de conexão PostgreSQL:', err);
logError('Erro de conexão PostgreSQL:', err);
});
//exporta o pool para ser usado em outros arquivos

View File

@ -1,7 +1,7 @@
// src/data/glpiDatabase.js
// Configuração da conexão com o banco de dados mariaDB do GLPI
const mysql = require('mysql2/promise');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { logError, logDebug } = require('../utils/logger');
const glpiPool = mysql.createPool({
host: process.env.GLPI_DB_HOST,
@ -18,7 +18,7 @@ const glpiPool = mysql.createPool({
// Testar conexão
glpiPool.on('connection', (connection) => {
logInfo('Nova conexão GLPI estabelecida');
logDebug('Nova conexão GLPI estabelecida');
});
glpiPool.on('error', (err) => {

View File

@ -1,10 +1,7 @@
// src/models/locationMappingModel.js
const pool = require('../data/database');
const { logInfo, logError } = require('../utils/logger');
class LocationMappingModel {
// Buscar entity_id do GLPI baseado no location_id do ServiceNow
static async getGlpiEntityId(snLocationId) {
try {
const query = `
@ -26,7 +23,6 @@ class LocationMappingModel {
}
}
// Buscar entity_id do GLPI baseado no location_name do ServiceNow
static async getGlpiEntityIdByName(snLocationName) {
try {
const query = `
@ -48,9 +44,6 @@ class LocationMappingModel {
}
}
// Buscar entity (id e name) do GLPI baseado no location_id do ServiceNow
static async getGlpiEntity(snLocationId) {
try {
const query = `
@ -74,12 +67,8 @@ class LocationMappingModel {
return null;
}
}
// Buscar entity (id e name) do GLPI baseado no location_name do ServiceNow
static async getGlpiEntityByName(snLocationName) {
try {
const query = `
@ -105,8 +94,14 @@ class LocationMappingModel {
}
}
module.exports = LocationMappingModel;
/**
* @module LocationMappingModel
* @description Este módulo gerencia a interação com a tabela `location_mapping`, que é crucial para a correta atribuição de tickets no GLPI.
*
* Funcionalidades:
* - `getGlpiEntityId` / `getGlpiEntityIdByName`: Buscam o ID da entidade GLPI correspondente a uma localização do ServiceNow, usando o ID ou o nome da localização.
* - `getGlpiEntity` / `getGlpiEntityByName`: Buscam o objeto completo da entidade GLPI (ID e nome), que é usado para formatar o título do ticket no GLPI.
* Este mapeamento permite que tickets abertos para uma determinada localidade no ServiceNow sejam automaticamente associados à entidade correta no GLPI.
*/

View File

@ -1,4 +1,3 @@
// src/models/syncControlModel.js
const pool = require('../data/database');
const { logError, logInfo } = require('../utils/logger');
@ -10,7 +9,6 @@ class SyncControlModel {
if (rows.length === 0) {
throw new Error(`Watermark para o sistema '${systemName}' não encontrada no banco de dados.`);
}
// Retorna no formato que o ServiceNow espera: 'YYYY-MM-DD HH:MM:SS'
return rows[0].last_watermark.toISOString().replace('T', ' ').substring(0, 19);
} catch (error) {
logError(error, `Erro ao buscar watermark para ${systemName}`);
@ -35,3 +33,15 @@ class SyncControlModel {
}
module.exports = SyncControlModel;
/**
* @module SyncControlModel
* @description Este módulo gerencia a tabela `sync_control`, que armazena metadados essenciais para o processo de sincronização,
* como as "marcas d'água" (watermarks).
*
* Funcionalidades:
* - `getWatermark(systemName)`: Obtém o timestamp do último registro processado com sucesso para um determinado sistema (ex: 'servicenow'). Isso garante que, na próxima execução, apenas os dados novos ou atualizados sejam buscados.
* - `setWatermark(systemName, watermark)`: Atualiza a marca d'água para um sistema após um ciclo de sincronização bem-sucedido.
*
* O uso de watermarks é fundamental para a eficiência do processo, evitando a re-leitura de todos os tickets a cada execução.
*/

View File

@ -1,11 +1,28 @@
// src/models/ticketGlpiModel.js
const glpiPool = require('../data/glpiDataBase');
const { logInfo, logError } = require('../utils/logger');
const LocationMappingModel = require('./locationMappingModel');
// Carrega o ID do usuário padrão do GLPI a partir das variáveis de ambiente
const GLPI_DEFAULT_USER_ID = parseInt(process.env.GLPI_DEFAULT_USER_ID, 10) || 1118;
const GLPI_DEFAULT_GROUP_ID = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30;
const GLPI_DEFAULT_ENTITY_ID = parseInt(process.env.GLPI_DEFAULT_ENTITY_ID, 10) || 127;
const GLPI_REQ_DEFAULT_JUSTIFICATIVA = process.env.GLPI_REQ_DEFAULT_JUSTIFICATIVA || 'Chamado aberto por colaborador CAOA sem Justificativa';
const GLPI_REQ_DEFAULT_DESCRIPTION = process.env.GLPI_REQ_DEFAULT_DESCRIPTION || 'Chamado aberto por colaborador CAOA sem Descricao';
const toMySqlDateTime = (value) => {
if (!value) return new Date();
if (typeof value === 'string') {
const m = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
class TicketGlpiModel {
// Mapeamento de status do ServiceNow para o GLPI
static mapStatus(snState) {
const statusMap = {
'Aguardando Atendimento': 1,
@ -13,16 +30,16 @@ class TicketGlpiModel {
'Em Espera': 4,
'Resolvido': 5,
'Encerrado - Omitido': 6,
'Encerrado': 6 // Mapeia para Fechado no GLPI
'Encerrado': 6
};
return statusMap[snState] || 1;
}
// Mapeamento de status do GLPI (numérico) para ServiceNow (string)
static mapGlpiStatusToString(glpiStatus) {
const statusMap = {
1: 'Aguardando Atendimento', // Novo
2: 'Em Atendimento', // Em atendimento (atribuído)
3: 'Em Atendimento', // Em planejamento (tratado como variacao de "em andamento" pro SN)
4: 'Em Espera', // Pendente
5: 'Resolvido', // Solucionado
6: 'Encerrado - Omitido' // Fechado
@ -30,7 +47,6 @@ class TicketGlpiModel {
return statusMap[glpiStatus] || 'Desconhecido';
}
// Mapeamento de prioridade
static mapPriority(snPriority) {
const priorityMap = {
'1': 5,
@ -42,7 +58,6 @@ class TicketGlpiModel {
return priorityMap[snPriority] || 3;
}
// Mapeamento de categoria
static mapCategory(shortDescription) {
const categoryMap = {
'Problemas com Telefonia Fixa': 5717,
@ -53,7 +68,6 @@ class TicketGlpiModel {
return categoryMap[shortDescription] || 5717;
}
// Mapeamento de SLA baseado no tipo e na descrição
static mapSlasIdTtr(ticketData) {
const isRequisicao = ticketData.type === 'requisicao';
const shortDesc = ticketData.short_description;
@ -73,10 +87,9 @@ class TicketGlpiModel {
if (!isRequisicao && isTelefonia) {
return 34;
}
return 0; // Default se nenhum critério for atendido
return 0;
}
// Formata o título do ticket
static async formatTitle(ticketData) {
try {
let entityData = null;
@ -92,22 +105,21 @@ class TicketGlpiModel {
const tituloSn = ticketData.short_description || 'Sem título';
return {
title: `${tipo} - ${entityName} - ${tituloSn}`,
entityId: entityData?.id || 127 // fallback para entity_id default
title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID
};
} catch (error) {
logError(error, 'Erro ao formatar título do ticket');
const tipo = ticketData.type === 'requisicao' ? 'REQUISIÇÃO' : 'INCIDENTE';
return {
title: `${tipo} - ${ticketData.short_description || 'Sem título'}`,
entityId: 127
entityId: GLPI_DEFAULT_ENTITY_ID
};
}
}
static formatDescription(ticketData) {
// Função auxiliar para extrair informações usando regex
const extractInfo = (regex, description) => {
if (!description) return null;
const match = description.match(regex);
@ -123,12 +135,32 @@ class TicketGlpiModel {
return extractInfo(/(\d{3,4})/i, description);
};
// Obtém email, telefone e ramal, usando os valores do ticketData ou extraindo da descrição, se necessário.
const email = ticketData.caller_email || extractEmailFromDescription(ticketData.description) || 'N/A';
const phone = ticketData.caller_phone || extractPhoneFromDescription(ticketData.description) || 'N/A';
const ramal = ticketData.caller_ramal || extractRamalFromDescription(ticketData.description) || 'N/A';
const rawDescription = ticketData.description;
const isRequest = ticketData.type === 'requisicao';
const justificationForDisplay = isRequest
? (ticketData.justificativa && ticketData.justificativa.trim()
? ticketData.justificativa
: GLPI_REQ_DEFAULT_JUSTIFICATIVA)
: null;
const descriptionForDisplay = isRequest
? (rawDescription && rawDescription.trim()
? rawDescription
: GLPI_REQ_DEFAULT_DESCRIPTION)
: (rawDescription || 'N/A');
const email = ticketData.caller_email || extractEmailFromDescription(rawDescription) || 'N/A';
const phone = ticketData.caller_phone || extractPhoneFromDescription(rawDescription) || 'N/A';
const ramal = ticketData.caller_ramal || extractRamalFromDescription(rawDescription) || 'N/A';
const requestExtraHtml = isRequest
? `
<div style="margin-top: 12px; padding: 12px; border: 1px solid #d9e2ec; border-left: 4px solid #2b6cb0; background-color: #f8fbff;">
<div style="font-weight: 700; margin-bottom: 8px;">Justificativa</div>
<div style="white-space: pre-wrap;">${justificationForDisplay}</div>
</div>
`
: '';
// Formata a descrição em HTML
let htmlDescription = `
<table style="width:100%; border-collapse: collapse;">
<tr style="background-color:#f2f2f2;">
@ -156,31 +188,28 @@ class TicketGlpiModel {
<td style="padding: 8px; border: 1px solid #ddd;">${ticketData.ticket_number || 'N/A'}</td>
</tr>
</table>
<br>
<strong>Descrição:</strong>
<hr>
${ticketData.description || 'N/A'}
${requestExtraHtml}
<div style="margin-top: 12px; padding: 12px; border: 1px solid #e2e8f0; border-left: 4px solid #4a5568; background-color: #fcfcfc;">
<div style="font-weight: 700; margin-bottom: 8px;">Descricao</div>
<div style="white-space: pre-wrap;">${descriptionForDisplay}</div>
</div>
`;
return htmlDescription;
}
// Cria um ticket no GLPI
static async createTicket(ticketData) {
const connection = await glpiPool.getConnection();
try {
await connection.beginTransaction();
// Buscar entity_id com base no location_id ou location_name
let entityId = 127; // Valor padrão (fallback)
let entityId = GLPI_DEFAULT_ENTITY_ID;
if (ticketData.location_id) {
entityId = await LocationMappingModel.getGlpiEntityId(ticketData.location_id) || entityId;
} else if (ticketData.location_name) {
entityId = await LocationMappingModel.getGlpiEntityIdByName(ticketData.location_name) || entityId;
}
// Extrai o email da descrição, se existir e não houver email no ticketData
let email = ticketData.caller_email;
if (!email && ticketData.description) {
const emailRegex = /[\w-\.]+@([\w-]+\.)+[\w-]{2,4}/g;
@ -190,23 +219,18 @@ class TicketGlpiModel {
}
}
// Formata o título do ticket
const { title: formattedTitle } = await TicketGlpiModel.formatTitle(ticketData);
// Formata a descrição para HTML
const formattedDescription = TicketGlpiModel.formatDescription(ticketData);
// Mapeia o tipo do ticket
const ticketType = ticketData.type === 'requisicao' ? 2 : 1;
// Determina o valor de slas_id_ttr
const slasIdTtr = TicketGlpiModel.mapSlasIdTtr(ticketData);
// Prepara os dados para o GLPI
const glpiTicketData = {
entities_id: entityId, // Usar o entity_id mapeado
entities_id: entityId,
name: formattedTitle,
date: ticketData.opened_at || new Date(),
date_mod: ticketData.updated_at || new Date(),
status: TicketGlpiModel.mapStatus(ticketData.state || '1'),
users_id_recipient: 917,
status: TicketGlpiModel.mapStatus(ticketData.state || '1'), // O status é mapeado
users_id_recipient: GLPI_DEFAULT_USER_ID,
requesttypes_id: 1,
content: formattedDescription,
urgency: 3,
@ -216,10 +240,10 @@ class TicketGlpiModel {
itilcategories_id: TicketGlpiModel.mapCategory(ticketData.short_description),
global_validation: 1,
date_creation: ticketData.opened_at || new Date(),
slas_id_ttr: slasIdTtr
slas_id_ttr: slasIdTtr,
externalid: ticketData.ticket_number
};
// Constrói a query dinamicamente e insere o ticket principal
const fields = Object.keys(glpiTicketData).join(', ');
const placeholders = Object.keys(glpiTicketData).map(() => '?').join(', ');
const values = Object.values(glpiTicketData);
@ -228,20 +252,18 @@ class TicketGlpiModel {
const [ticketResult] = await connection.execute(ticketQuery, values);
const ticketId = ticketResult.insertId;
// Adiciona o requerente
await connection.execute(
`INSERT INTO glpi_tickets_users
(tickets_id, users_id, type, use_notification, alternative_email)
VALUES (?, ?, 1, 1, '')`,
[ticketId, 917]
VALUES (?, ?, 1, 1, '')`, // 1 = Requerente
[ticketId, GLPI_DEFAULT_USER_ID]
);
// Adiciona o grupo
await connection.execute(
`INSERT INTO glpi_groups_tickets
(tickets_id, groups_id, type)
VALUES (?, ?, 2)`,
[ticketId, 30]
[ticketId, GLPI_DEFAULT_GROUP_ID]
);
await connection.commit();
@ -258,7 +280,6 @@ class TicketGlpiModel {
}
}
// Verifica se um ticket já existe no GLPI
static async ticketExists(snTicketNumber) {
try {
const query = `
@ -279,12 +300,13 @@ class TicketGlpiModel {
return null;
}
}
// Buscar followups (notas) de um ticket no GLPI
static async getFollowupsByItemId(itemId) {
const connection = await glpiPool.getConnection();
try {
const query = 'SELECT id, content, date, date_creation, date_mod FROM glpi_itilfollowups WHERE items_id = ? AND users_id != 971';
const [rows] = await connection.execute(query, [itemId]);
const query = 'SELECT id, content, date, date_creation, date_mod FROM glpi_itilfollowups WHERE items_id = ? AND users_id != ?';
const [rows] = await connection.execute(query,[itemId,GLPI_DEFAULT_USER_ID]);
return rows;
} catch (error) {
logError(error, 'Erro ao buscar followups do ticket');
@ -295,11 +317,17 @@ class TicketGlpiModel {
static async insertComment(comment, items_id) {
const connection = await glpiPool.getConnection();
try {
const query = 'INSERT INTO glpi_itilfollowups(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position) VALUES(?, ?, NOW(), ?, ?, NOW(), NOW(), ?)';
const values = ['Ticket', items_id, 971, comment.content , 1];
const createdAt = toMySqlDateTime(comment.created_at);
const updatedAt = toMySqlDateTime(comment.updated_at || comment.created_at);
const query = `
INSERT INTO glpi_itilfollowups
(itemtype, items_id, date, users_id, content, date_creation, date_mod, timeline_position)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = ['Ticket', items_id, createdAt, GLPI_DEFAULT_USER_ID, comment.content, createdAt, updatedAt, 1];
const [result] = await connection.execute(query, values);
return [{ id: result.insertId }]; // Retorna o ID inserido
return [{ id: result.insertId }];
}catch(error){
logError(`Erro ao inserir o comentário no GLPI: ${error}`);
} finally {
@ -307,11 +335,40 @@ class TicketGlpiModel {
}
}
// Busca tickets que foram fechados ou solucionados no GLPI
static async insertTask(task, items_id) {
const connection = await glpiPool.getConnection();
try {
const createdAt = toMySqlDateTime(task.created_at);
const updatedAt = toMySqlDateTime(task.updated_at || task.created_at);
// Registra work_notes do ServiceNow como tarefa no GLPI.
const query = `
INSERT INTO glpi_tickettasks
(tickets_id, users_id, state, content, date, date_mod, actiontime)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const values = [
items_id,
GLPI_DEFAULT_USER_ID,
2,
task.content,
createdAt,
updatedAt,
0
];
const [result] = await connection.execute(query, values);
return [{ id: result.insertId }];
} catch(error){
logError(`Erro ao inserir tarefa no GLPI: ${error}`);
throw error;
} finally {
if (connection) connection.release();
}
}
static async getClosedTickets(watermark, limit = 100) {
const connection = await glpiPool.getConnection();
try {
// Status 5 = Solucionado, Status 6 = Fechado
const query = `
SELECT
t.id,
@ -332,17 +389,14 @@ class TicketGlpiModel {
}
}
// Fecha um ticket no GLPI, adicionando uma solução
static async closeTicket(ticketId, solutionContent) {
const connection = await glpiPool.getConnection();
try {
await connection.beginTransaction();
// Adiciona a solução
const solutionQuery = `INSERT INTO glpi_itilsolutions (itemtype, items_id, content, users_id_editor, date_creation, date_mod) VALUES ('Ticket', ?, ?, ?, NOW(), NOW())`;
await connection.execute(solutionQuery, [ticketId, solutionContent, 971]); // 971 = ID do usuário da integração
await connection.execute(solutionQuery, [ticketId, solutionContent, GLPI_DEFAULT_USER_ID]);
// Atualiza o status do ticket para "Solucionado" (status 5)
const updateTicketQuery = `UPDATE glpi_tickets SET status = 5, solvedate = NOW() WHERE id = ?`;
await connection.execute(updateTicketQuery, [ticketId]);
@ -356,7 +410,6 @@ class TicketGlpiModel {
}
}
// Busca a solução de um ticket no GLPI
static async getTicketSolution(glpiTicketId) {
const connection = await glpiPool.getConnection();
try {
@ -375,17 +428,14 @@ class TicketGlpiModel {
}
}
// Força o fechamento de um ticket (muda status para 6)
static async forceCloseTicket(ticketId) {
const connection = await glpiPool.getConnection();
try {
// Atualiza o status do ticket para "Fechado" (status 6)
const updateTicketQuery = `UPDATE glpi_tickets SET status = 6, closedate = NOW() WHERE id = ?`;
await connection.execute(updateTicketQuery, [ticketId]);
logInfo(`✅ Ticket GLPI ${ticketId} forçado para o estado Fechado (6).`);
} catch (error) {
logError(error, `❌ Erro ao forçar fechamento do ticket ${ticketId} no GLPI`);
// Não lança o erro para não parar o fluxo principal por uma ação secundária
} finally {
if (connection) connection.release();
}
@ -396,11 +446,10 @@ class TicketGlpiModel {
const connection = await glpiPool.getConnection();
try {
await connection.beginTransaction();
status = this.mapStatus(status);
const mappedStatus = this.mapStatus(status);
const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`;
await connection.execute(updateQuery, [status, ticketId]);
await connection.execute(updateQuery, [mappedStatus, ticketId]);
await connection.commit();
} catch (error) {
@ -412,7 +461,6 @@ class TicketGlpiModel {
}
}
// Busca o status atual de um ticket no GLPI
static async getTicketStatus(glpiTicketId) {
const connection = await glpiPool.getConnection();
try {
@ -430,19 +478,36 @@ class TicketGlpiModel {
}
}
static async unassignGroupFromTicket(ticketId) {
const connection = await glpiPool.getConnection();
const groupId = parseInt(process.env.GLPI_DEFAULT_GROUP_ID, 10) || 30;
try {
const query = `DELETE FROM glpi_groups_tickets WHERE tickets_id = ? AND groups_id = ?`;
await connection.execute(query, [ticketId, groupId]);
logInfo(`✅ Grupo ${groupId} removido do ticket GLPI ${ticketId}.`);
} catch (error) {
logError(error, `❌ Erro ao remover grupo ${groupId} do ticket ${ticketId} no GLPI`);
throw error;
} finally {
if (connection) connection.release();
}
}
}
/**
* @module TicketGlpiModel
* @description Este módulo fornece métodos para gerenciar tickets no GLPI.
* Inclui funcionalidades para mapear status, prioridade e categoria,
* formatar a descrição do ticket, determinar o SLA, criar tickets e
* verificar se um ticket existe no GLPI.
* @description Este módulo é a camada de acesso a dados para o banco de dados do GLPI.
* Ele encapsula todas as interações diretas com o banco de dados MySQL do GLPI,
* oferecendo uma interface clara para criar, atualizar e consultar tickets e seus componentes.
*
* O módulo usa expressões regulares para extrair informações da descrição do ticket,
* como email, número de telefone e ramal, quando essas informações não são
* fornecidas diretamente. Também formata a descrição em HTML para melhor
* apresentação no GLPI.
* Principais Funcionalidades:
* - **Mapeamento de Dados**: Contém funções (`mapStatus`, `mapPriority`, `mapCategory`, etc.) para traduzir os dados do formato do ServiceNow para o formato esperado pelo GLPI.
* - **Formatação de Conteúdo**: `formatTitle` e `formatDescription` preparam o título e a descrição do ticket, enriquecendo-os com informações da entidade e formatando a descrição em uma tabela HTML para melhor legibilidade.
* - **Criação de Tickets (`createTicket`)**: Orquestra a criação de um novo ticket no GLPI, incluindo a inserção na tabela principal (`glpi_tickets`), a associação de usuários e grupos.
* - **Consulta de Dados**: Oferece métodos para verificar a existência de um ticket (`ticketExists`), buscar seu status (`getTicketStatus`), soluções (`getTicketSolution`) e comentários/follow-ups (`getFollowupsByItemId`).
* - **Atualização de Tickets**: Inclui métodos para atualizar o status (`updateStatus`), adicionar comentários (`insertComment`), e fechar tickets, seja de forma padrão com uma solução (`closeTicket`) ou forçando o estado para "Fechado" (`forceCloseTicket`).
*
* O módulo interage com o banco de dados GLPI através da conexão glpiPool.
* Este modelo é fundamental para abstrair a complexidade do esquema de banco de dados do GLPI e garantir que as operações sejam realizadas de forma transacional e segura.
*/
module.exports = TicketGlpiModel;

View File

@ -1,16 +1,14 @@
// src/models/ticketSnModel.js
const pool = require('../data/database');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { logInfo, logError, logDebug } = require('../utils/logger');
class TicketSnModel {
// Salva ou atualiza um ticket no banco de dados
static async saveTicket(ticketData, typeTicket) {
const query = `
INSERT INTO tickets_sn
(ticket_number, sys_id, short_description, status, description, caller_id, caller_email,
location_id, location_name, ramal, telefone, opened_at, tipo)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
location_id, location_name, ramal, telefone, justificativa, opened_at, tipo)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (ticket_number)
DO UPDATE SET
sys_id = EXCLUDED.sys_id,
@ -23,21 +21,17 @@ class TicketSnModel {
location_name = EXCLUDED.location_name,
ramal = EXCLUDED.ramal,
telefone = EXCLUDED.telefone,
justificativa = EXCLUDED.justificativa,
opened_at = EXCLUDED.opened_at,
tipo = EXCLUDED.tipo
RETURNING id, (xmax = 0) AS "wasInserted"`; // xmax=0 é um truque do PostgreSQL para saber se foi INSERT
RETURNING id, (xmax = 0) AS "wasInserted"`;
// Trata a diferença na estrutura do campo 'number' entre Incidentes e Requisições
const ticketNumber = typeof ticketData.number === 'object' ? ticketData.number?.value : ticketData.number;
// Mapeamento inteligente de campos baseado no tipo de ticket
let callerName, callerEmail, ramal, telefone, sysId, shortDescription, state, description, locationId, locationName, openedAt;
let callerName, callerEmail, ramal, telefone, justificativa, sysId, shortDescription, state, description, locationId, locationName, openedAt;
if (typeTicket === 'incidente') {
callerName = ticketData.caller_id?.display_value;
// O campo de email para incidentes vem como um objeto, precisamos extrair o valor.
const emailObject = ticketData['caller_id.email'] || {};
callerEmail = (typeof emailObject === 'object' ? emailObject.value : emailObject) || null;
ramal = null; // Incidentes não têm essa variável por padrão
@ -46,6 +40,7 @@ class TicketSnModel {
shortDescription = ticketData.short_description || null;
state = ticketData.state || null;
description = ticketData.description || null;
justificativa = null;
const locationLink = ticketData.location?.link;
locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null;
locationName = ticketData.location?.display_value || null;
@ -54,12 +49,12 @@ class TicketSnModel {
callerName = ticketData.opened_by?.display_value || null;
callerEmail = ticketData['opened_by.email'] || null;
ramal = ticketData['variables.numero_ramal'] || null;
telefone = ticketData['variables.telephone_favorecido_rh'] || ticketData.telefone || null;
telefone = ticketData['variables.telephone_favorecido_rh'] || ticketData['variables.telephone_favorecido_ti'] || ticketData.telefone || null;
sysId = ticketData.sys_id || null;
shortDescription = ticketData.short_description || null;
state = ticketData.state || null;
description = ticketData.description || null;
// Para requisições, o sys_id da localização vem no final da URL do link
justificativa = ticketData.justificativa || null;
const locationLink = ticketData.location?.link;
locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null;
locationName = ticketData.location?.display_value || null;
@ -78,6 +73,7 @@ class TicketSnModel {
locationName,
ramal,
telefone,
justificativa,
openedAt
? (typeof openedAt === 'string' && openedAt.includes('/')
? new Date(openedAt.replace(/(\d{2})\/(\d{2})\/(\d{4})/, '$3-$2-$1'))
@ -89,7 +85,7 @@ class TicketSnModel {
try {
const result = await pool.query(query, values);
const wasInserted = result.rows[0].wasInserted; // Corrigido para corresponder ao alias "wasInserted"
const wasInserted = result.rows[0].wasInserted;
if (wasInserted) {
logInfo(`✅ NOVO ticket salvo! ID: ${result.rows[0].id}`, {
@ -98,7 +94,10 @@ class TicketSnModel {
action: 'insert'
});
} else {
logInfo(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
// Fica em debug: a margem de seguranca de 6h do watermark reenvia o mesmo ticket
// toda vez que ele ainda esta "quente", mesmo sem mudanca real de campo. O sinal
// de mudanca de status de verdade ja e logado em processSyncController.js.
logDebug(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
ticket_id: result.rows[0].id,
ticket_number: values[0],
action: 'update'
@ -112,11 +111,11 @@ class TicketSnModel {
}
}
// Busca tickets que ainda não foram sincronizados com o GLPI
static async getPendingTickets() {
try {
const query = `
SELECT
tsync.id AS ticket_sync_id,
ts.id,
ts.ticket_number,
ts.short_description,
@ -128,6 +127,7 @@ class TicketSnModel {
ts.location_name,
ts.ramal,
ts.telefone,
ts.justificativa,
ts.opened_at,
ts.tipo,
ts.created_at,
@ -140,7 +140,7 @@ class TicketSnModel {
`;
const result = await pool.query(query);
logInfo(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
logDebug(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
return result.rows;
} catch (error) {
@ -149,7 +149,6 @@ class TicketSnModel {
}
}
// Busca um ticket pelo número
static async findByTicketNumber(ticketNumber) {
try {
const query = `
@ -166,8 +165,6 @@ class TicketSnModel {
}
}
// Busca o status de um ticket pelo ID
static async getStatus(ticketId) {
try {
const query = `
@ -181,9 +178,6 @@ class TicketSnModel {
}
}
// Busca um ticket pelo ID
static async findById(ticketId) {
try {
const query = `
@ -200,24 +194,20 @@ class TicketSnModel {
}
}
// Atualiza os dados de um ticket
static async updateTicket(ticketId, updateData) {
try {
const fields = [];
const values = [];
let paramCount = 1;
// Construir dinamicamente a query baseada nos campos fornecidos
for (const [key, value] of Object.entries(updateData)) {
fields.push(`${key} = $${paramCount}`);
values.push(value);
paramCount++;
}
// Adicionar updated_at
fields.push(`updated_at = NOW()`);
// Adicionar o ID no final dos valores
values.push(ticketId);
const query = `
@ -247,12 +237,13 @@ class TicketSnModel {
/**
* @module TicketSnModel
* @description Este módulo fornece métodos para interagir com a tabela de tickets do ServiceNow (tickets_sn) no banco de dados.
* Inclui funcionalidades para salvar um ticket, buscar tickets pendentes para sincronização com o GLPI,
* e buscar/atualizar tickets específicos.
* @description Este módulo serve como a camada de acesso a dados para a tabela `tickets_sn`, que armazena uma cópia local dos dados dos tickets do ServiceNow.
* Manter uma cópia local otimiza as consultas e reduz a dependência de chamadas de API repetitivas ao ServiceNow.
*
* A função saveTicket insere ou atualiza um ticket na tabela tickets_sn.
* A função getPendingTickets busca tickets que ainda não foram sincronizados com o GLPI (não possuem registro em ticket_sync)
* e ignora os tickets com status 'Encerrado' ou 'Encerrado - Omitido'.
* Principais Funcionalidades:
* - `saveTicket(ticketData, typeTicket)`: Insere um novo ticket ou atualiza um existente (UPSERT) na tabela `tickets_sn`. Ele mapeia de forma inteligente os campos de incidentes e requisições, que possuem estruturas diferentes na API do ServiceNow. Retorna se o registro foi inserido ou atualizado.
* - `getPendingTickets()`: Busca todos os tickets que estão no estado 'pending_check', indicando que foram coletados do ServiceNow mas ainda não foram processados para criação no GLPI.
* - Funções de Busca (`findByTicketNumber`, `findById`, `getStatus`): Métodos para recuperar registros de tickets ou seus status com base em diferentes identificadores.
* - `updateTicket(ticketId, updateData)`: Atualiza dinamicamente os campos de um registro de ticket existente.
*/
module.exports = TicketSnModel;

View File

@ -1,15 +1,11 @@
// src/models/ticketSyncModel.js
const { query } = require('winston');
const pool = require('../data/database');
const { logError } = require('../utils/logger');
const { logError, logInfo } = require('../utils/logger');
class TicketSyncModel {
// Cria um registro de sincronização para um ticket
static async createSyncRecord(data) {
try {
const query = `INSERT INTO ticket_sync (
sn_ticket_id,
glpi_ticket_id,
@ -20,11 +16,13 @@ class TicketSyncModel {
sn_sync_status,
glpi_sync_status,
source_last
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (sn_ticket_id) DO NOTHING
RETURNING id`;
const values = [
data.sn_ticket_id,
data.glpi_ticket,
data.glpi_ticket_id,
data.last_sn_sync,
data.last_glpi_sync,
data.created_at,
@ -38,13 +36,11 @@ class TicketSyncModel {
await pool.query(query, values);
} catch (error) {
logError(error, '❌ Erro ao criar registro de sincronização');
logError(error, 'ERRO: Erro ao criar registro de sincronizacao');
throw error;
}
}
// Buscar tickets que foram criados com sucesso para sincronizar comentários
static async getTicketsToSync(glpi_sync_status, sn_sync_status) {
try {
const query = 'SELECT id, glpi_ticket_id, sn_ticket_id FROM ticket_sync WHERE glpi_sync_status = $1 AND sn_sync_status = $2';
@ -52,80 +48,63 @@ class TicketSyncModel {
const { rows } = await pool.query(query, values);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets para sincronizar');
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
throw error;
}
}
// Atualiza apenas o source_last de um ticket
static async updateSourceLast(snTicketId, source) {
try {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2';
await pool.query(query, [source, snTicketId]);
} catch (error) {
logError(error, ` Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
logError(error, `ERRO: Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
// Atualiza o status de sincronização de um ticket
static async updateStatus(ticketSyncId, glpiStatus, snStatus) {
try {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
// Atualiza o status de sincronização de um ticket e o source last
static async updateStatusAndSourceLast(ticketSyncId, glpiStatus, snStatus, source_last) {
try {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status= $2, source_last = $4 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId, source_last]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
// Atualiza apenas o source_last de um ticket
static async updateSourceLast(snTicketId, source) {
try {
const query = 'UPDATE ticket_sync SET source_last = $1 WHERE sn_ticket_id = $2';
await pool.query(query, [source, snTicketId]);
} catch (error) {
logError(error, `❌ Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
static async updateStatusAndGlpiTicket(ticketSyncId, glpiStatus, snStatus, glpiTicketId) {
try {
const query = 'UPDATE ticket_sync SET glpi_sync_status = $1, sn_sync_status = $2, glpi_ticket_id = $4 WHERE sn_ticket_id = $3';
await pool.query(query, [glpiStatus, snStatus, ticketSyncId, glpiTicketId]);
} catch (error) {
logError(error, `❌ Erro ao atualizar status de sincronização para o ID: ${ticketSyncId}`);
logError(error, `ERRO: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
throw error;
}
}
// Buscar o ID de sincronização baseado no glpi_ticket_id
static async getIdSyncByGlpiId(glpiTicketId) {
try {
const query = 'SELECT id FROM ticket_sync WHERE glpi_ticket_id = $1';
const { rows } = await pool.query(query, [glpiTicketId]);
return rows[0] ? rows[0].id : null;
} catch (error) {
logError(error, `❌ Erro ao buscar ID de sincronização por GLPI ID: ${glpiTicketId}`);
logError(error, `ERRO: Erro ao buscar ID de sincronizacao por GLPI ID: ${glpiTicketId}`);
throw error;
}
}
// Buscar o ID de sincronização baseado no sys_id do SN
static async getBySysId(sysId) {
try {
const query = `
@ -135,36 +114,34 @@ class TicketSyncModel {
const { rows } = await pool.query(query, [sysId]);
return rows[0] || null;
} catch (error) {
logError(error, `❌ Erro ao buscar registro de sincronização por sys_id: ${sysId}`);
logError(error, `ERRO: Erro ao buscar registro de sincronizacao por sys_id: ${sysId}`);
throw error;
}
}
// Buscar o ID de sincronização baseado no sn_ticket_id
static async getIdSyncBySnId(snTicketId) {
try {
const query = 'SELECT id FROM ticket_sync WHERE sn_ticket_id = $1';
const { rows } = await pool.query(query, [snTicketId]);
return rows[0] ? rows[0].id : null;
} catch (error) {
logError(error, `❌ Erro ao buscar ID de sincronização por SN ID: ${snTicketId}`);
logError(error, `ERRO: Erro ao buscar ID de sincronizacao por SN ID: ${snTicketId}`);
throw error;
}
}
static async getByTicketNumber(ticketNumber) {
try {
console.log(ticketNumber)
const query = 'SELECT * FROM ticket_sync WHERE sn_ticket_id = $1';
const { rows } = await pool.query(query, [ticketNumber]);
return rows[0] || null;
} catch (error) {
logError(`${error}, Erro ao buscar ticket ${ticketNumber}}` )
logError(`${error}, ERRO: Erro ao buscar ticket ${ticketNumber}}` )
}
}
// Buscar tickets que estão sincronizados
static async getTicketsSynced() {
try {
const query = `
@ -180,12 +157,11 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets para sincronizar');
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
throw error;
}
}
// Buscar tickets que estão ativos para monitoramento (synced ou solved)
static async getTicketsToMonitor() {
try {
const query = `
@ -200,16 +176,39 @@ class TicketSyncModel {
FROM ticket_sync tsync
JOIN tickets_sn tsn ON tsync.sn_ticket_id = tsn.id
WHERE (tsync.glpi_sync_status IN ('synced', 'solved') OR tsync.sn_sync_status IN ('synced', 'solved'))
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`;
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')
AND (tsync.glpi_sync_status != 'error_permanent' AND tsync.sn_sync_status != 'error_permanent')`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, '❌ Erro ao buscar tickets para monitorar');
logError(error, 'ERRO: Erro ao buscar tickets para monitorar');
throw error;
}
}
static async getTicketsForClosureMonitor() {
try {
const query = `
SELECT
tsync.id,
tsync.glpi_ticket_id,
tsync.sn_ticket_id,
tsync.source_last,
tsync.sn_sync_status,
tsync.glpi_sync_status
FROM ticket_sync tsync
WHERE tsync.glpi_ticket_id IS NOT NULL
AND tsync.glpi_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
AND tsync.sn_sync_status NOT IN ('closed', 'ignored', 'error_permanent')
`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, 'ERRO: Falha ao buscar tickets para monitoramento de fechamento GLPI');
throw error;
}
}
// Buscar tickets que precisam ter o fechamento sincronizado
static async getTicketsPendingSolve() {
try {
const query = `
@ -227,28 +226,48 @@ class TicketSyncModel {
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, ' Erro ao buscar tickets pendentes de fechamento');
logError(error, 'ERRO: Erro ao buscar tickets pendentes de fechamento');
throw error;
}
}
// Busca todos os tickets que estão em algum estado de erro
static async getTicketsInErrorState() {
try {
const query = `
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status, error_retry_count
FROM ticket_sync
WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%'
WHERE (sn_sync_status LIKE '%error%' AND sn_sync_status != 'error_permanent')
OR (glpi_sync_status LIKE '%error%' AND glpi_sync_status != 'error_permanent')
`;
const { rows } = await pool.query(query);
return rows;
} catch (error) {
logError(error, '❌ Erro ao buscar tickets em estado de erro');
logError(error, 'ERRO: Erro ao buscar tickets em estado de erro');
throw error;
}
}
static async bumpErrorRetryCount(snTicketId) {
try {
const query = 'UPDATE ticket_sync SET error_retry_count = error_retry_count + 1 WHERE sn_ticket_id = $1 RETURNING error_retry_count';
const { rows } = await pool.query(query, [snTicketId]);
return rows[0] ? rows[0].error_retry_count : null;
} catch (error) {
logError(error, `ERRO: Erro ao incrementar error_retry_count para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
static async markPermanentError(snTicketId) {
try {
const query = "UPDATE ticket_sync SET glpi_sync_status = 'error_permanent', sn_sync_status = 'error_permanent' WHERE sn_ticket_id = $1";
await pool.query(query, [snTicketId]);
} catch (error) {
logError(error, `ERRO: Erro ao marcar error_permanent para o ticket SN ID: ${snTicketId}`);
throw error;
}
}
// Atualiza os timestamps de última sincronização
static async updateLastSync(snTicketId, system) {
try {
let fieldToUpdate;
@ -257,23 +276,21 @@ class TicketSyncModel {
} else if (system === 'glpi') {
fieldToUpdate = 'last_glpi_sync';
} else {
return; // Não faz nada se o sistema for inválido
return;
}
const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`;
await pool.query(query, [snTicketId]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar last_sync para o ID: ${snTicketId}`);
// Não lançar erro para não parar o fluxo principal
logError(`${error} ERRO: Erro ao atualizar last_sync para o ID: ${snTicketId}`);
}
}
// Atualiza o status de sincronização de um ticket usando o GLPI ID
static async updateStatusByGlpiId(glpiTicketId, snStatus, glpiStatus) {
try {
const query = 'UPDATE ticket_sync SET sn_sync_status = $1, glpi_sync_status = $2 WHERE glpi_ticket_id = $3';
await pool.query(query, [snStatus, glpiStatus, glpiTicketId]);
} catch (error) {
logError(`${error} ❌ Erro ao atualizar status de sincronização para o GLPI ID: ${glpiTicketId}`);
logError(`${error} ERRO: Erro ao atualizar status de sincronizacao para o GLPI ID: ${glpiTicketId}`);
throw error;
}
}
@ -282,14 +299,16 @@ class TicketSyncModel {
}
/**
* @module TicketSyncModel
* @description Este módulo fornece métodos para interagir com a tabela de sincronização de tickets (ticket_sync) no banco de dados.
* Inclui funcionalidades para buscar tickets para sincronização, atualizar o status de sincronização
* e buscar o ID de sincronização baseado no glpi_ticket_id.
* @description Este modulo e a camada de acesso a tabela `ticket_sync`, que atua como o cerebro do processo de sincronizacao.
* Ele armazena o estado de cada ticket em ambos os sistemas (GLPI e ServiceNow) e controla o fluxo de atualizacoes.
*
* A função getTicketsToSync busca todos os tickets que possuem IDs tanto no GLPI quanto no ServiceNow.
* A função updateStatus atualiza os status de sincronização do GLPI e do ServiceNow para um ticket específico.
* A função getIdSyncByGlpiId retorna o ID de sincronização baseado no glpi_ticket_id.
* Essas funcionalidades são essenciais para manter a consistência e o rastreamento dos tickets entre os dois sistemas.
* Principais Funcionalidades:
* - **Gerenciamento de Estado**: Metodos como `updateStatus`, `updateStatusAndSourceLast` e `updateStatusAndGlpiTicket` sao usados para modificar os status de sincronizacao (ex: 'pending_check', 'synced', 'solved', 'closed', 'error').
* - **Controle de Fluxo (`source_last`)**: A coluna `source_last` e gerenciada por este modelo para indicar qual sistema realizou a ultima modificacao, prevenindo atualizacoes conflitantes (race conditions).
* - **Selecao de Tickets para Processamento**: Metodos como `getTicketsToMonitor`, `getTicketsInErrorState`, e `getTicketsSynced` fornecem listas de tickets para os diferentes controladores processarem, com base em seus status atuais.
* - **Criacao e Consulta**: `createSyncRecord` cria o registro inicial de um ticket, e varias funcoes de busca (`getBySysId`, `getIdSyncByGlpiId`, etc.) permitem localizar registros de sincronizacao.
*
* A tabela `ticket_sync` e a fonte da verdade para o estado da integracao de cada ticket individualmente.
*/

View File

@ -1,4 +1,3 @@
// src/models/ticketUpdateModel.js
const ticketSyncModel = require('./ticketSyncModel');
const pool = require('../data/database');
const { logError } = require('../utils/logger');
@ -11,19 +10,19 @@ class TicketUpdateModel {
INSERT INTO ticket_updates (ticket_sync_id, update_type, source_system, content, author, created_at, updated_at, source_id, destiny_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (source_id) DO UPDATE SET
ticket_sync_id = $1,
update_type = $2,
source_system = $3,
content = $4,
author = $5,
created_at = $6,
updated_at = $7,
destiny_id = $9
ticket_sync_id = EXCLUDED.ticket_sync_id,
update_type = EXCLUDED.update_type,
source_system = EXCLUDED.source_system,
content = EXCLUDED.content,
author = EXCLUDED.author,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
destiny_id = EXCLUDED.destiny_id
RETURNING *;
`;
const values = [
updateData.ticket_sync_id,
updateData.update_type, // Corrigido para usar o tipo de atualização dinâmico
updateData.update_type,
updateData.source_system,
updateData.content,
updateData.author,
@ -102,5 +101,35 @@ class TicketUpdateModel {
throw error;
}
}
static async getPendingBySourcePrefix(sourcePrefix) {
try {
const query = `
SELECT *
FROM ticket_updates
WHERE source_id LIKE $1
AND destiny_id IS NULL
ORDER BY created_at ASC NULLS FIRST, id ASC
`;
const { rows } = await pool.query(query, [`${sourcePrefix}%`]);
return rows;
} catch (error) {
logError(error, `Erro ao buscar updates pendentes por prefixo: ${sourcePrefix}`);
throw error;
}
}
}
module.exports = TicketUpdateModel;
/**
* @module TicketUpdateModel
* @description Este módulo gerencia a tabela `ticket_updates`, que serve como um log persistente de todas as atualizações (comentários, mudanças de status, etc.)
* que ocorrem em ambos os sistemas. Ele é fundamental para a sincronização de comentários e para evitar a duplicação de dados.
*
* Funcionalidades:
* - `insert(updateData)`: Insere ou atualiza (UPSERT) um registro de atualização. A cláusula `ON CONFLICT` é usada para evitar duplicatas com base no `source_id`.
* - `getBySourceId(sourceId)`: Verifica se uma atualização específica (ex: um comentário com um determinado ID) foi registrada no banco de dados.
* - `updateDestinyId(sourceId, destinyId)`: Após uma atualização ser sincronizada com o sistema de destino, este método armazena o ID gerado no destino, completando o ciclo de mapeamento.
* - `getLatestUpdate(...)`: Busca a atualização mais recente para um determinado ticket, útil para obter notas de fechamento, por exemplo.
*/

View File

@ -0,0 +1,11 @@
-- =============================================
-- Migracao: contador de tentativas de reprocessamento de erro em ticket_sync
-- Motivo: processErrorController resetava tickets em estado *error* para
-- reprocessamento a cada ciclo, sem limite - um ticket com falha
-- deterministica (dado invalido, ACL permanente, etc.) reprocessava
-- para sempre, gerando ruido de log/API sem nunca sinalizar que
-- precisa de intervencao manual.
-- =============================================
ALTER TABLE ticket_sync
ADD COLUMN IF NOT EXISTS error_retry_count INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,22 @@
-- =============================================
-- Migracao: adiciona UNIQUE(sn_ticket_id) em ticket_sync
-- Motivo: nada no banco impedia duas linhas de ticket_sync para o mesmo
-- ticket SN (check-then-act sem transacao em processSyncController.js),
-- o que permitiria criar dois tickets no GLPI para o mesmo chamado SN.
--
-- IMPORTANTE: rodar o passo 1 primeiro. Se aparecer alguma linha, o ALTER TABLE
-- do passo 2 vai falhar (constraint violada) e as duplicatas encontradas
-- precisam ser reconciliadas manualmente (decidir qual linha manter, atualizar
-- referencias em ticket_updates, apagar a(s) linha(s) excedente(s)) antes de
-- tentar novamente.
-- =============================================
-- 1. Verificar se ja existe alguma duplicata hoje.
SELECT sn_ticket_id, COUNT(*) AS total, array_agg(id) AS ticket_sync_ids
FROM ticket_sync
GROUP BY sn_ticket_id
HAVING COUNT(*) > 1;
-- 2. Se o passo 1 nao retornou nenhuma linha, aplicar a constraint.
ALTER TABLE ticket_sync
ADD CONSTRAINT ticket_sync_sn_ticket_id_unique UNIQUE (sn_ticket_id);

View File

@ -0,0 +1,25 @@
-- =============================================
-- MANUTENCAO / RESET CONTROLADO - SN <-> GLPI
-- =============================================
-- Use este arquivo apenas para manutencao operacional.
-- Nao execute em producao sem backup e janela aprovada.
BEGIN;
-- Diagnostico rapido
SELECT * FROM ticket_sync WHERE glpi_sync_status != 'ignored';
SELECT * FROM tickets_sn WHERE status != 'Encerrado' AND status != 'Encerrado - Omitido';
SELECT * FROM public.location_mapping;
-- Limpeza de tickets abertos (mantem chamados encerrados)
DELETE FROM tickets_sn
WHERE status != 'Encerrado'
AND status != 'Encerrado - Omitido';
-- Reinicia watermarks para reprocessar coleta completa
UPDATE sync_control
SET last_watermark = '1970-01-01 00:00:00+00',
last_run_status = 'manual_reset',
last_run_at = NOW();
COMMIT;

View File

@ -34,6 +34,7 @@ CREATE TABLE tickets_sn (
location_name VARCHAR(255), -- Nome da localização no ServiceNow
ramal VARCHAR(100), -- Ramal do solicitante
telefone VARCHAR(100), -- Telefone do solicitante
justificativa TEXT, -- Justificativa da requisicao (catalog variable)
opened_at TIMESTAMP, -- Data e hora de abertura no ServiceNow
created_at TIMESTAMP DEFAULT NOW(), -- Data e hora de criação do registro
updated_at TIMESTAMP DEFAULT NOW() -- Data e hora da última atualização do registro
@ -98,6 +99,9 @@ CREATE TABLE ticket_updates (
ON DELETE CASCADE
);
-- Adciona a restrição de unicidade na coluna source_id
ALTER TABLE ticket_updates ADD CONSTRAINT ticket_updates_source_id_unique UNIQUE (source_id);
-- Índices para melhor performance nas consultas frequentes
CREATE INDEX idx_ticket_updates_sync ON ticket_updates(ticket_sync_id);
CREATE INDEX idx_ticket_updates_source ON ticket_updates(source_system);
@ -222,6 +226,7 @@ COMMENT ON COLUMN tickets_sn.location_id IS 'ID da localização no ServiceNow';
COMMENT ON COLUMN tickets_sn.location_name IS 'Nome da localização no ServiceNow';
COMMENT ON COLUMN tickets_sn.ramal IS 'Ramal do solicitante';
COMMENT ON COLUMN tickets_sn.telefone IS 'Telefone do solicitante';
COMMENT ON COLUMN tickets_sn.justificativa IS 'Justificativa da requisicao vinda das variaveis de catalogo';
COMMENT ON COLUMN tickets_sn.opened_at IS 'Data e hora de abertura no ServiceNow';
COMMENT ON COLUMN tickets_sn.created_at IS 'Data e hora de criação do registro';
COMMENT ON COLUMN tickets_sn.updated_at IS 'Data e hora da última atualização do registro';
@ -244,9 +249,6 @@ COMMENT ON COLUMN ticket_updates.update_type IS 'Tipo de atualização: task, no
COMMENT ON COLUMN ticket_updates.source_system IS 'Sistema de origem: sn (ServiceNow) ou glpi';
COMMENT ON COLUMN ticket_updates.content IS 'Conteúdo da atualização/tarefa';
COMMENT ON COLUMN ticket_updates.author IS 'Autor da atualização';
COMMENT ON COLUMN ticket_updates.created_at_source IS 'Data e hora de criação no sistema de origem';
COMMENT ON COLUMN ticket_updates.sync_status IS 'Status da sincronização: pending, completed, error';
COMMENT ON COLUMN ticket_updates.sync_date IS 'Data e hora da sincronização';
COMMENT ON COLUMN ticket_updates.created_at IS 'Data e hora de criação do registro';
COMMENT ON COLUMN ticket_updates.updated_at IS 'Data e hora da última atualização do registro';
@ -284,3 +286,7 @@ COMMENT ON COLUMN sync_control.last_run_at IS 'Timestamp da última vez que o jo
-- Registro inicial para a marca d'água do GLPI.
INSERT INTO sync_control (system_name, last_watermark) VALUES ('glpi', '1970-01-01 00:00:00+00');
-- Migracao para bases existentes:
ALTER TABLE tickets_sn ADD COLUMN IF NOT EXISTS justificativa TEXT;

View File

@ -0,0 +1,4 @@
pandas
psycopg2-binary
mysql-connector-python
python-dotenv

View File

@ -1,29 +1,71 @@
# scripts/update_location_mapping.py
import csv
#!/usr/bin/env python3
import os
from dotenv import load_dotenv
import time
import psycopg2
import mysql.connector
from mysql.connector import Error
import logging
from datetime import datetime
import os
from dotenv import load_dotenv
import pandas as pd
from datetime import timedelta
import logging
from logging.handlers import TimedRotatingFileHandler
from datetime import datetime, timedelta
from pathlib import Path
# Carrega as variáveis do .env
load_dotenv()
# ===========================
# 🔧 Configuração do Ambiente
# ===========================
# Configuração de logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('location_mapping.log'),
logging.StreamHandler()
]
env = os.getenv('NODE_ENV', 'production')
env_file = f".env.{env}"
env_path = Path(__file__).resolve().parents[3] / env_file
load_dotenv(dotenv_path=env_path)
print(f"🌎 Ambiente carregado: {env} ({env_path})")
# ===========================
# 🪵 Configuração de Logs
# ===========================
LOG_DIR = Path(__file__).resolve().parents[3] / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Cria um logger
logger = logging.getLogger("LocationMappingLogger")
logger.setLevel(logging.INFO)
# Evita que os logs se propaguem para o logger raiz
logger.propagate = False
# Formato do log
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Handler para o console (útil em desenvolvimento)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
# Handler para rotação diária de arquivos de log gerais
app_log_handler = TimedRotatingFileHandler(
LOG_DIR / "location-app.log", when="midnight", interval=1, backupCount=10, encoding="utf-8"
)
app_log_handler.setFormatter(formatter)
# Handler para rotação diária de arquivos de log de erro
error_log_handler = TimedRotatingFileHandler(
LOG_DIR / "location-error.log", when="midnight", interval=1, backupCount=10, encoding="utf-8"
)
error_log_handler.setLevel(logging.ERROR)
error_log_handler.setFormatter(formatter)
# Adiciona os handlers ao logger
logger.addHandler(console_handler)
logger.addHandler(app_log_handler)
logger.addHandler(error_log_handler)
# ===========================
# 💾 Configurações dos Bancos
# ===========================
# Configurações de banco de dados a partir do .env
SNGLPI_DB_CONFIG = {
'host': os.getenv('SNGLPI_DB_HOST'),
'port': os.getenv('SNGLPI_DB_PORT'),
@ -41,44 +83,43 @@ GLPI_DB_CONFIG = {
'charset': os.getenv('GLPI_DB_CHARSET', 'utf8mb4')
}
# Caminho para o arquivo CSV
CSV_PATH = os.getenv('LOCATION_MAPPING_CSV_PATH')
# Função para verificar se o CSV foi modificado recentemente
# ===========================
# 🔍 Funções auxiliares
# ===========================
def csv_recently_modified(minutes=2):
"""Verifica se o CSV foi alterado recentemente."""
if not os.path.exists(CSV_PATH):
logging.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
logger.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
return False
last_mod_time = datetime.fromtimestamp(os.path.getmtime(CSV_PATH))
if datetime.now() - last_mod_time <= timedelta(minutes=minutes):
logging.info(f"CSV modificado recentemente ({last_mod_time}), rodando atualização.")
logger.info(f"CSV modificado recentemente ({last_mod_time})")
return True
else:
logging.info(f"CSV não foi modificado nos últimos {minutes} minutos (última modificação: {last_mod_time}).")
logger.debug(f"CSV não foi modificado nos últimos {minutes} minutos.")
return False
# Função para conectar ao banco SNGLPI
def get_snglpi_connection():
try:
conn = psycopg2.connect(**SNGLPI_DB_CONFIG)
return conn
return psycopg2.connect(**SNGLPI_DB_CONFIG)
except Exception as e:
logging.error(f"Erro ao conectar ao banco SNGLPI: {e}")
logger.error(f"Erro ao conectar ao banco SNGLPI: {e}", exc_info=True)
return None
# Função para conectar ao banco GLPI
def get_glpi_connection():
try:
conn = mysql.connector.connect(**GLPI_DB_CONFIG)
return conn
return mysql.connector.connect(**GLPI_DB_CONFIG)
except Error as e:
logging.error(f"Erro ao conectar ao banco GLPI: {e}")
logger.error(f"Erro ao conectar ao banco GLPI: {e}", exc_info=True)
return None
# Função para buscar location_id no SNGLPI
def find_sn_location_id(location_name, cursor):
try:
query = """
@ -91,10 +132,10 @@ def find_sn_location_id(location_name, cursor):
result = cursor.fetchone()
return result[0] if result else None
except Exception as e:
logging.error(f"Erro ao buscar location_id para {location_name}: {e}")
logger.error(f"Erro ao buscar location_id ({location_name}): {e}", exc_info=True)
return None
# Função para buscar entity_id no GLPI
def find_glpi_entity_id(entity_name, cursor):
try:
query = """
@ -107,96 +148,74 @@ def find_glpi_entity_id(entity_name, cursor):
result = cursor.fetchone()
return result[0] if result else None
except Exception as e:
logging.error(f"Erro ao buscar entity_id para {entity_name}: {e}")
logger.error(f"Erro ao buscar entity_id ({entity_name}): {e}", exc_info=True)
return None
# Função principal para atualizar a tabela de mapeamento
def update_mapping_table():
"""Atualiza a tabela de mapeamento a partir do CSV."""
try:
# Verificar se o arquivo CSV existe
if not os.path.exists(CSV_PATH):
logging.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
logger.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
return False
# Conectar aos bancos
snglpi_conn = get_snglpi_connection()
glpi_conn = get_glpi_connection()
with get_snglpi_connection() as snglpi_conn, get_glpi_connection() as glpi_conn:
if not snglpi_conn or not glpi_conn:
return False
if not snglpi_conn or not glpi_conn:
return False
with snglpi_conn.cursor() as snglpi_cur, glpi_conn.cursor() as glpi_cur:
df = pd.read_csv(CSV_PATH, encoding='latin-1', sep=';')
df['sn_location_id'] = None
df['glpi_entity_id'] = None
snglpi_cur = snglpi_conn.cursor()
glpi_cur = glpi_conn.cursor()
mappings = []
for _, row in df.iterrows():
sn_name = row['sn_location_name']
glpi_name = row['glpi_entity_name']
# Ler CSV
df = pd.read_csv(CSV_PATH, encoding='latin-1', sep=';')
print(df.head())
print(df.columns)
print(df)
# Adicionar colunas para IDs
df['sn_location_id'] = None
df['glpi_entity_id'] = None
sn_id = find_sn_location_id(sn_name, snglpi_cur)
glpi_id = find_glpi_entity_id(glpi_name, glpi_cur)
if glpi_id:
mappings.append((sn_id, sn_name, glpi_id, glpi_name))
else:
logger.warning(f"Entity não encontrada: {glpi_name}")
# Processar cada linha do CSV
mappings = []
for index, row in df.iterrows():
sn_location_name = row['sn_location_name']
glpi_entity_name = row['glpi_entity_name']
# Buscar IDs
sn_id = find_sn_location_id(sn_location_name, snglpi_cur)
glpi_id = find_glpi_entity_id(glpi_entity_name, glpi_cur)
# Preencher o DataFrame
df.loc[index, 'sn_location_id'] = sn_id
df.loc[index, 'glpi_entity_id'] = glpi_id
if glpi_id:
# Se o glpi_entity_id for encontrado, adiciona para inserção
mappings.append((sn_id, sn_location_name, glpi_id, glpi_entity_name))
logging.debug(f"Mapeamento: {sn_location_name} -> {glpi_entity_name} (ID: {glpi_id})")
else:
logging.warning(f"Não foi possível encontrar entity_id para: {glpi_entity_name}")
# Atualizar tabela de mapeamento
if mappings:
# Limpar tabela existente
snglpi_cur.execute("DELETE FROM location_mapping")
# Inserir novos mapeamentos
insert_query = """
INSERT INTO location_mapping
(sn_location_id, sn_location_name, glpi_entity_id, glpi_entity_name)
VALUES (%s, %s, %s, %s)
"""
snglpi_cur.executemany(insert_query, mappings)
snglpi_conn.commit()
logging.info(f"Tabela atualizada com {len(mappings)} mapeamentos")
else:
logging.warning("Nenhum mapeamento válido encontrado para atualização")
# Fechar conexões
snglpi_cur.close()
glpi_cur.close()
snglpi_conn.close()
glpi_conn.close()
if mappings:
snglpi_cur.execute("DELETE FROM location_mapping")
insert_query = """
INSERT INTO location_mapping
(sn_location_id, sn_location_name, glpi_entity_id, glpi_entity_name)
VALUES (%s, %s, %s, %s)
"""
snglpi_cur.executemany(insert_query, mappings)
snglpi_conn.commit()
logger.info(f"Tabela atualizada com {len(mappings)} registros.")
else:
logger.warning("Nenhum mapeamento válido encontrado.")
return True
except Exception as e:
logging.error(f"Erro no processo de atualização: {e}")
# Rollback em caso de erro
if 'snglpi_conn' in locals():
snglpi_conn.rollback()
logger.error(f"Erro no processo de atualização: {e}", exc_info=True)
return False
# ===========================
# 🔁 Loop Contínuo
# ===========================
if __name__ == "__main__":
logging.info("Iniciando atualização de mapeamento de localizações")
if csv_recently_modified(minutes=2):
success = update_mapping_table()
if success:
logging.info("Atualização concluída com sucesso")
else:
logging.error("Falha na atualização")
logger.info("🚀 Iniciando monitoramento contínuo do Location Mapping")
while True:
try:
if csv_recently_modified(minutes=2):
success = update_mapping_table()
if success:
logger.info("✅ Atualização concluída com sucesso.")
else:
logger.error("❌ Falha na atualização.")
time.sleep(60)
except Exception as e:
logger.error(f"Erro inesperado no loop principal: {e}", exc_info=True)
time.sleep(60)

View File

@ -1,44 +1,117 @@
// src/services/glpiCommentService.js
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const { logInfo, logError } = require('../utils/logger');
const { logInfo, logError, logDebug } = require('../utils/logger');
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
const { log } = require('winston');
// Sincroniza comentários do GLPI para o Banco de Dados local
const toTitleCase = (text) => text
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
const normalizeAuthorName = (rawAuthor) => {
const source = String(rawAuthor || 'ServiceNow').trim();
const localPart = source.includes('@') ? source.split('@')[0] : source;
const normalizedRaw = localPart.replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!normalizedRaw) {
return 'ServiceNow';
}
const parts = normalizedRaw.split(' ');
if (parts[0].toLowerCase() === 't' && parts.length > 1) {
return `Tecnico ${toTitleCase(parts.slice(1).join(' '))}`;
}
return toTitleCase(normalizedRaw);
};
const formatSNUpdateForGlpi = (update, options = {}) => {
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const normalizedAuthor = normalizeAuthorName(update.author);
const author = escapeHtml(normalizedAuthor);
const content = escapeHtml(update.content || '');
const isAutomaticTask = update.update_type === 'task' && /registro alterado por:/i.test(String(update.content || ''));
const isSnClosureMessage = update.update_type === 'comment' && /chamado resolvido|chamado encerrado/i.test(String(update.content || ''));
const isSnReopenMessageByText = update.update_type === 'comment' && /reabert|reopen/i.test(String(update.content || ''));
const isSnReopenMessageByContext = Boolean(options.isReopenContextComment);
const isSnReopenMessage = isSnReopenMessageByText || isSnReopenMessageByContext;
if (isAutomaticTask) {
return `
<div style="border:1px solid #f6d084; border-left:4px solid #b7791f; background:#fffaf0; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#744210; margin-bottom:6px;">Tarefa automatica do ServiceNow</div>
<div style="color:#5f370e; font-size:12px; white-space: pre-wrap;">
${content}
</div>
</div>`;
}
if (isSnClosureMessage) {
return `
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento automatico do ServiceNow</div>
<div style="color:#742a2a; font-size:12px; white-space: pre-wrap;">${content}</div>
<div style="margin-top:8px; color:#742a2a; font-size:12px;">Chamado resolvido no ServiceNow e removido do monitoramento automatico de atualizacoes.</div>
</div>`;
}
if (isSnReopenMessage) {
return `
<div style="border:1px solid #9ae6b4; border-left:4px solid #2f855a; background:#f0fff4; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#22543d; margin-bottom:6px;">Reabertura de chamado no ServiceNow</div>
<div style="color:#276749; font-size:12px; white-space: pre-wrap;">${content}</div>
</div>`;
}
return `
<div style="border:1px solid #d6e9ff; border-left:4px solid #2b6cb0; background:#f7fbff; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#1a365d; margin-bottom:6px;">${author}</div>
<div style="color:#4a5568; font-size:12px; white-space: pre-wrap;">
${content}
</div>
</div>`;
};
const syncCommentsGlpitoSN = async (ticket) => {
try {
logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...');
logDebug('Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
if (comments.length === 0) {
logInfo(`Nenhum comentário encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
if (!Array.isArray(comments) || comments.length === 0) {
logDebug(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return;
}
logInfo(`📝 ${comments.length} comentários encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'comment_collected', 'pending_cmt_check');
logDebug(`${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
let hasError = false;
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
for (const comment of comments) {
try {
const sanitized = sanitizeGLPIComment(comment);
// Checa idempotencia local (barato, sem chamada externa) antes de sanitizar e consultar o SN.
// A maioria dos comentarios de um ticket ja monitorado ha varios ciclos ja tem destiny_id
// gravado, entao nem precisa sanitizar nem chamar a API do SN pra saber disso.
const existingComment = await TicketUpdateModel.getBySourceId(comment.id);
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized);
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
// Caso 1: já sincronizado
if (existingComment && existsInSN) {
logInfo(`🔁 Comentário ${comment.id} já sincronizado.`);
// Não muda o source_last aqui, pois não houve nova sincronização
if (existingComment && existingComment.destiny_id) {
logDebug(`Comentario ${comment.id} ja sincronizado.`);
continue;
}
const sanitized = sanitizeGLPIComment(comment);
const existsInSN = await existingCommentInServiceNow(ticket.sn_ticket_id, sanitized);
if (existingComment && existsInSN) {
logDebug(`Comentario ${comment.id} ja sincronizado.`);
continue;
}
// Caso 2: está no SN mas não no banco
if (!existingComment && existsInSN) {
await TicketUpdateModel.insert({
ticket_sync_id: syncId,
@ -48,13 +121,11 @@ const syncCommentsGlpitoSN = async (ticket) => {
created_at: new Date(comment.date_creation),
updated_at: new Date(comment.date_mod),
source_id: comment.id,
destiny_id: existsInSN.destiny_id
destiny_id: existsInSN
});
// Não muda o source_last aqui
continue;
}
// Caso 3: existe no banco mas falta no SN
if (existingComment && !existsInSN) {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'pending_cmt_sync');
const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized);
@ -63,7 +134,6 @@ const syncCommentsGlpitoSN = async (ticket) => {
continue;
}
// Caso 4: não existe em nenhum lugar → sincroniza ambos
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'pending_cmt_sync', 'pending_cmt_sync');
const snCommentId = await syncCommentToServiceNow(ticket.sn_ticket_id, sanitized);
await TicketUpdateModel.insert({
@ -76,42 +146,66 @@ const syncCommentsGlpitoSN = async (ticket) => {
source_id: comment.id,
destiny_id: snCommentId
});
// Apenas aqui, quando um comentário é efetivamente sincronizado, passamos o bastão,
// mas SOMENTE se o ticket não estiver já resolvido ou fechado.
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'GLPI');
}
} catch (err) {
logError(`Erro ao sincronizar comentário ${comment.id}: ${err}`);
logError(`Erro ao sincronizar comentario ${comment.id}: ${err}`);
hasError = true;
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error');
}
}
logInfo('✅ Sincronização de comentários GLPI → SN concluída!');
// Evita deixar ticket preso em status transitorio de comentario,
// o que pode bloquear o processamento de status/fechamento.
if (!hasError) {
const finalGlpiStatus = ['solved', 'closed'].includes(ticket.glpi_sync_status) ? ticket.glpi_sync_status : 'synced';
const finalSnStatus = ['solved', 'closed'].includes(ticket.sn_sync_status) ? ticket.sn_sync_status : 'synced';
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, finalGlpiStatus, finalSnStatus);
}
logDebug('OK: Sincronizacao de comentarios GLPI -> SN concluida!');
} catch (error) {
logError(`Erro geral na sincronização: ${error}`);
logError(`Erro geral na sincronizacao: ${error}`);
}
};
const syncCommentsSNtoGlpi = async (comments, ticket) => {
logInfo('🔄 Iniciando sincronização de comments do ServiceNow para o GLPI...');
const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
logDebug('Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
for (const comment of comments) {
const orderedComments = [...comments].sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
let reopenHighlightPending = Boolean(options.highlightReopenContext);
for (const comment of orderedComments) {
try {
const commentId = await TicketGlpiModel.insertComment(comment, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
// Passa o bastão para o SN, mas SOMENTE se o ticket não estiver já resolvido ou fechado.
const isReopenContextComment = reopenHighlightPending && comment.update_type === 'comment';
const formattedPayload = {
...comment,
content: formatSNUpdateForGlpi(comment, { isReopenContextComment })
};
const isTask = comment.update_type === 'task';
const insertedId = isTask
? await TicketGlpiModel.insertTask(formattedPayload, ticket.glpi_ticket_id)
: await TicketGlpiModel.insertComment(formattedPayload, ticket.glpi_ticket_id);
if (isReopenContextComment) {
reopenHighlightPending = false;
}
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
if (ticket.sn_sync_status === 'synced' && ticket.glpi_sync_status === 'synced') {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'synced', 'SNOW');
}
await TicketUpdateModel.updateDestinyId(comment.source_id, commentId[0].id);
await TicketUpdateModel.updateDestinyId(comment.source_id, insertedId[0].id);
logInfo(`✅ Comentário inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
logInfo(`OK: ${isTask ? 'Tarefa' : 'Comentario'} inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
} catch (error) {
logError(`⚠️ Erro ao processar comentário do ticket ${ticket.sn_ticket_id}: ${error}`);
logError(`ERRO: Erro ao processar atualizacao do ticket ${ticket.sn_ticket_id}: ${error}`);
try {
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error');
@ -122,18 +216,20 @@ const syncCommentsSNtoGlpi = async (comments, ticket) => {
}
};
/**
* @module glpiCommentService
* @description Este módulo fornece serviços para sincronizar comentários do GLPI para o banco de dados local.
* A função syncCommentsGlpi busca tickets que foram criados com sucesso e sincroniza seus comentários,
* garantindo que apenas novos comentários sejam inseridos no banco de dados.synced
* Utiliza os modelos TicketSnModel, TicketGlpiModel, TicketSyncModel e TicketUpdateModel para interagir com o banco de dados.
* Atualiza o status de sincronização após cada comentário processado.
* Inclui logging detalhado para monitorar o progresso e identificar erros durante o processo de sincronização.
* @description Este servico orquestra a sincronizacao de comentarios (follow-ups/notas) entre GLPI e ServiceNow.
*
* Principais Funcionalidades:
* - `syncCommentsGlpitoSN(ticket)`: Sincroniza comentarios do GLPI para o ServiceNow. Esta e a funcao mais complexa, pois implementa uma logica de 4 casos para garantir a consistencia e evitar duplicatas:
* 1. **Ja Sincronizado**: O comentario existe no banco local e no ServiceNow. Nenhuma acao e necessaria.
* 2. **Existe no SN, Falta no Local**: O comentario foi sincronizado em uma execucao anterior que falhou. O registro local e criado para refletir a realidade.
* 3. **Existe no Local, Falta no SN**: O comentario foi registrado localmente, mas a sincronizacao com o SN falhou. A funcao reenvia o comentario para o ServiceNow.
* 4. **Nao Existe em Nenhum**: Um novo comentario do GLPI. Ele e enviado para o ServiceNow e um novo registro e criado no banco local.
*
* - `syncCommentsSNtoGlpi(comments, ticket)`: Sincroniza comentarios do ServiceNow para o GLPI. Este fluxo e mais simples: para cada novo comentario do SN (ja filtrado e salvo localmente pelo `servicenowService`), ele o insere no GLPI.
*
* Em ambos os fluxos, o "bastao" (`source_last`) e atualizado para indicar qual sistema originou a ultima atualizacao, mantendo o controle do fluxo de status.
*/
module.exports = {

View File

@ -1,27 +1,26 @@
// src/services/glpiService.js
const TicketSnModel = require('../models/ticketSnModel');
const TicketGlpiModel = require('../models/ticketGlpiModel');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { logInfo, logError, logWarning } = require('../utils/logger');
const { addWorkNoteToServiceNow } = require('./servicenowService');
const { logInfo, logError, logDebug } = require('../utils/logger');
const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry, reassignTicketInServiceNow } = require('./servicenowService');
const { stripHTML } = require('../utils/commentSanitizer');
const outOfScopeSolutionTypeId = Number(process.env.GLPI_OOS_SOLUTION_TYPE_ID || 27);
const caoaTiGroupId = process.env.SERVICENOW_CAOA_TI_GROUP_ID;
// Método principal que o controller vai chamar
const syncTicketsToGlpi = async () => {
try {
logInfo('🔄 Iniciando sincronização para GLPI...');
// 1. Buscar tickets pendentes
logDebug('🔄 Iniciando sincronização para GLPI...');
const pendingTickets = await TicketSnModel.getPendingTickets();
if (pendingTickets.length === 0) {
logInfo('✅ Nenhum ticket pendente para sincronizar');
logDebug('✅ Nenhum ticket pendente para sincronizar');
return;
}
logInfo(`📋 Encontrados ${pendingTickets.length} tickets para sincronizar`);
// 2. Processar cada ticket
for (const ticket of pendingTickets) {
await processSingleTicket(ticket);
}
@ -34,46 +33,43 @@ const syncTicketsToGlpi = async () => {
}
};
// Processar um ticket individual
const processSingleTicket = async (ticket) => {
try {
logInfo(`🔍 Processando ticket: ${ticket.ticket_number}`);
// 1. Verifica se o ticket já possui um ID do GLPI no nosso banco de sincronização.
if (ticket.glpi_ticket_id) {
logInfo(`✅ Ticket ${ticket.ticket_number} já possui GLPI ID (${ticket.glpi_ticket_id}) registrado. Apenas atualizando status.`);
await TicketSyncModel.updateStatus(ticket.id, 'synced', 'synced');
const linkedOk = await updateExternalTicketInServiceNow(ticket.id, ticket.glpi_ticket_id);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, ticket.glpi_ticket_id, linkedOk);
return;
}
// 2. Se não tem ID local, verifica se o ticket existe no GLPI (buscando pelo número).
const existingTicket = await TicketGlpiModel.ticketExists(ticket.ticket_number);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp
await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
if (existingTicket) {
logInfo(`✅ Ticket ${ticket.ticket_number} encontrado no GLPI. Atualizando registro local com o ID: ${existingTicket}`);
// --- NOVA VALIDAÇÃO: Verificar status do ticket existente no GLPI ---
const glpiLiveStatus = await TicketGlpiModel.getTicketStatus(existingTicket);
if (glpiLiveStatus === 6) { // Status 6 = Fechado no GLPI
if (glpiLiveStatus === 6) {
logInfo(`Ticket GLPI ${existingTicket} já está Fechado. Marcando ticket SN ${ticket.ticket_number} como 'closed'/'ignored' e não monitorado.`);
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'ignored', 'closed', existingTicket);
return; // Não processa mais este ticket
return;
}
// --- FIM DA NOVA VALIDAÇÃO ---
// Atualiza nosso registro de sincronização com o ID do GLPI encontrado.
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', existingTicket);
const existingLinkOk = await updateExternalTicketInServiceNow(ticket.id, existingTicket);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, existingTicket, existingLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} já existe no GLPI! GLPI ID: ${existingTicket}`);
return; // Adiciona este return para evitar a criação de um novo ticket
return;
}
// 3. Converter dados para formato GLPI
const glpiData = {
ticket_number: ticket.ticket_number,
short_description: ticket.short_description,
description: ticket.description,
justificativa: ticket.justificativa,
caller_id: ticket.caller_id,
caller_email: ticket.caller_email,
caller_phone: ticket.telefone,
@ -86,35 +82,16 @@ const processSingleTicket = async (ticket) => {
opened_at: ticket.opened_at,
updated_at: ticket.updated_at
};
// Lógica para buscar descrição de sc_item_option se a descrição atual estiver vazia
// e for um ticket de requisição (apenas para criação de novos tickets)
if (glpiData.type === 'requisicao' && (!glpiData.description || glpiData.description.trim() === '')) {
logInfo(`Descrição vazia para requisição ${ticket.ticket_number}. Tentando buscar de sc_item_option.`);
// Você precisará saber o nome interno da variável de catálogo que contém a descrição.
// Por exemplo, se for 'justificativa', use 'justificativa'.
const scItemOptionDescription = await require('./servicenowService').fetchScItemOptionValue(ticket.sys_id, 'justificativa');
if (scItemOptionDescription) {
glpiData.description = scItemOptionDescription;
logInfo(`Descrição atualizada para requisição ${ticket.ticket_number} a partir de sc_item_option.`);
} else {
logWarning(`Não foi possível obter descrição de sc_item_option para requisição ${ticket.ticket_number}.`);
}
}
// 3. Inserir no GLPI
const glpiTicketId = await TicketGlpiModel.createTicket(glpiData);
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp
// 4. Atualizar registro de sincronização
await TicketSyncModel.updateLastSync(ticket.id, 'glpi');
await TicketSyncModel.updateStatusAndGlpiTicket(ticket.id, 'synced', 'synced', glpiTicketId);
const externalLinkOk = await updateExternalTicketInServiceNow(ticket.id, glpiTicketId);
await queueExternalTicketLinkRetry(ticket.ticket_sync_id, ticket.id, glpiTicketId, externalLinkOk);
logInfo(`✅ Ticket ${ticket.ticket_number} sincronizado! GLPI ID: ${glpiTicketId}`);
} catch (error) {
logError(error, `❌ Erro no ticket ${ticket.ticket_number}`);
// Se temos um registro de sync, atualizar o status para error
try {
await TicketSyncModel.updateStatus(ticket.id, 'error', 'collected');
} catch (innerError) {
@ -123,68 +100,68 @@ const processSingleTicket = async (ticket) => {
}
};
const checkGlpiStatusChanges = async () => {
const isOutOfScopeSolution = (solution) => solution?.solutiontypes_id === outOfScopeSolutionTypeId;
/**
* Regra "fora de escopo": nota no SN + transferencia para fila CAOA + remove grupo do GLPI + fecha
* o ticket no GLPI. Antes existia uma copia quase identica em processGlpiClosureController.js e
* outra em processStatusController.js (uma delas nao removia o grupo do GLPI) - consolidado aqui.
*/
const handleOutOfScopeResolution = async (ticket, solution) => {
const justification = solution?.content ? stripHTML(solution.content) : 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fora do escopo Sothis. Chamado transferido para a fila CAOA Redes com justificativa: ${justification}`);
await reassignTicketInServiceNow(ticket.sn_ticket_id, caoaTiGroupId);
await TicketGlpiModel.unassignGroupFromTicket(ticket.glpi_ticket_id);
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} fora do escopo: grupo removido, transferido para CAOA e fechado.`);
};
const addServiceNowClosureNoteToGlpi = async (ticket, collaboratorName, finalStatus = 'encerrado') => {
const sourceId = `sn_closure_note:${ticket.glpi_ticket_id}:${finalStatus}`;
try {
logInfo('📡 Verificando mudanças de status nos tickets do GLPI...');
// 1. Busca todos os tickets que estão sendo monitorados
const ticketsToMonitor = await TicketSyncModel.getTicketsToMonitor();
if (ticketsToMonitor.length === 0) {
logInfo('Nenhum ticket ativo para verificar status no GLPI.');
return;
const alreadySent = await TicketUpdateModel.getBySourceId(sourceId);
if (alreadySent) {
logInfo(`Nota de encerramento do ServiceNow ja registrada para o GLPI ${ticket.glpi_ticket_id} (status '${finalStatus}'). Ignorando reenvio.`);
return { ok: true, isNew: false };
}
for (const ticket of ticketsToMonitor) {
// 2. Para cada ticket, busca seu status real no GLPI
const glpiStatus = await TicketGlpiModel.getTicketStatus(ticket.glpi_ticket_id);
const resolvedBy = collaboratorName && collaboratorName.trim()
? collaboratorName.trim()
: 'colaborador CAOA';
const statusText = finalStatus === 'solucionado'
? 'Chamado solucionado no Service Now e removido do monitoramento.'
: 'Chamado encerrado no Service Now e removido do monitoramento.';
// 3. Compara o status real com o que temos no nosso banco
const currentDbStatus = ticket.glpi_sync_status;
const glpiStatusAsString = TicketGlpiModel.mapGlpiStatusToString(glpiStatus).toLowerCase().replace(/ /g, '_'); // ex: 'em_atendimento'
const styledMessage = `
<div style="border:1px solid #fecaca; border-left:4px solid #c53030; background:#fff5f5; padding:12px; border-radius:4px;">
<div style="font-weight:700; color:#9b2c2c; margin-bottom:6px;">Encerramento realizado pelo Service Now</div>
<div style="color:#742a2a;">
${statusText}
</div>
<div style="margin-top:8px; color:#742a2a; font-size:12px;">
Responsavel: ${resolvedBy}
</div>
</div>`;
// Se o status no GLPI mudou para Solucionado (5) e nosso banco ainda não reflete isso
if (glpiStatus === 5 && currentDbStatus !== 'solved' && currentDbStatus !== 'closed') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Solucionado. Preparando para sincronização...`);
const solution = await TicketGlpiModel.getTicketSolution(ticket.glpi_ticket_id);
// REGRA "FORA DO ESCOPO"
if (solution?.solutiontypes_id === 27) {
logInfo(`REGRA DETECTADA: Ticket ${ticket.glpi_ticket_id} está fora do escopo (SolutionType ID: 27).`);
const justification = solution?.content || 'Ticket marcado como "Fora do escopo" no GLPI.';
await addWorkNoteToServiceNow(ticket.sn_ticket_id, `Ticket fechado no GLPI com a justificativa: ${justification}`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', 'ignored');
await TicketGlpiModel.forceCloseTicket(ticket.glpi_ticket_id);
continue; // Pula para o próximo ticket
}
// FLUXO NORMAL DE RESOLUÇÃO
const sanitizedContent = solution?.content ? stripHTML(solution.content) : 'Chamado solucionado sem notas.';
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
source_system: 'glpi',
update_type: 'close_message',
content: sanitizedContent,
author: null,
created_at: solution?.date_mod || new Date(),
source_id: `solution-${ticket.glpi_ticket_id}`,
});
// Marca o ticket para ser resolvido no SN
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'solved', 'pending_solve', 'GLPI');
} else if (glpiStatus === 6 && currentDbStatus !== 'closed') {
logInfo(`Ticket GLPI ${ticket.glpi_ticket_id} foi Fechado permanentemente. Marcando como 'closed'.`);
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'closed', ticket.sn_sync_status);
} else if (glpiStatus < 5 && glpiStatusAsString !== currentDbStatus && ticket.source_last !== 'GLPI') {
// Para outros status (reabertura, etc.), apenas passa o bastão.
logInfo(`Mudança de status (aberto) detectada no GLPI para o ticket ${ticket.glpi_ticket_id}. De '${currentDbStatus}' para '${glpiStatusAsString}'.`);
await TicketSyncModel.updateSourceLast(ticket.sn_ticket_id, 'GLPI');
}
}
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
update_type: 'close_notice',
source_system: 'sn',
content: styledMessage,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: 'done'
});
logInfo(`Nota de encerramento do ServiceNow adicionada ao GLPI ${ticket.glpi_ticket_id}.`);
return { ok: true, isNew: true };
} catch (error) {
logError(error, '🚨Falha ao verificar mudanças de status no GLPI');
logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`);
return { ok: false, isNew: false };
}
};
@ -192,23 +169,35 @@ const closeTicketInGlpi = async (ticket) => {
try {
logInfo(`🔒 Iniciando fechamento do chamado GLPI ${ticket.glpi_ticket_id}...`);
// Busca a mensagem de fechamento vinda do ServiceNow
const closeMessageRecord = await TicketUpdateModel.getLatestUpdate(ticket.id, 'servicenow');
const solutionContent = closeMessageRecord?.content || 'Chamado encerrado via integração com o ServiceNow.';
// Chama o model para fechar o ticket no GLPI
await TicketGlpiModel.closeTicket(ticket.glpi_ticket_id, solutionContent);
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
logInfo(`✅ Chamado ${ticket.glpi_ticket_id} solucionado com sucesso no GLPI.`);
} catch (error) {
logError(error, `🚨Falha ao fechar chamado no GLPI: ${ticket.glpi_ticket_id}`);
throw error; // Propaga o erro para o controller tratar
throw error;
}
};
module.exports = {
syncTicketsToGlpi,
checkGlpiStatusChanges,
closeTicketInGlpi
closeTicketInGlpi,
addServiceNowClosureNoteToGlpi,
isOutOfScopeSolution,
handleOutOfScopeResolution
};
/**
* @module glpiTicketService
* @description Este serviço contém a lógica de negócio para a criação e atualização de tickets no GLPI.
* Ele atua como um intermediário entre os controladores e os modelos de dados.
*
* Principais Funcionalidades:
* - `syncTicketsToGlpi()`: Função principal que busca tickets pendentes no banco de dados local (previamente coletados do ServiceNow) e orquestra sua criação no GLPI.
* - `processSingleTicket(ticket)`: Processa um único ticket. Verifica se ele existe no GLPI para evitar duplicatas. Se não existir, formata os dados (incluindo o enriquecimento da descrição para requisições) e chama o `TicketGlpiModel` para criar o ticket. Se existir, apenas atualiza o registro de sincronização.
* - `isOutOfScopeSolution(solution)` / `handleOutOfScopeResolution(ticket, solution)`: Regra "fora do escopo" compartilhada por `processGlpiClosureController` e `processStatusController`.
* - `closeTicketInGlpi(ticket)`: Orquestra o processo de fechamento de um ticket no GLPI. Ele busca a nota de resolução vinda do ServiceNow e a utiliza para adicionar uma solução no GLPI antes de mudar o status para "Solucionado".
*/

View File

@ -1,90 +1,165 @@
// src/services/servicenowService.js
const axios = require('axios');
const apiConfig = require('../../config/apiConfig');
const { stripHTML } = require('../utils/commentSanitizer');
const TicketSnModel = require('../models/ticketSnModel');
const { logInfo, logError, logSync } = require('../utils/logger');
const { logInfo, logError, logSync, logDebug } = require('../utils/logger');
const TicketUpdateModel = require('../models/ticketUpdateModel');
const TicketSyncModel = require('../models/ticketSyncModel');
const { log } = require('winston');
const resolvedBySysId = process.env.SERVICENOW_RESOLVED_BY_SYSID || 'b131d6ed1b2ba510c5e163923b4bcb77';
const servicenowDefaultUser = process.env.SERVICENOW_DEFAULT_USER || 'SOTHIS.CAOA';
const shouldIgnoreDefaultUserJournal = process.env.SERVICENOW_IGNORE_DEFAULT_USER_JOURNAL !== 'false';
const externalTicketField = process.env.SERVICENOW_EXTERNAL_TICKET_FIELD || 'u_external_tickets';
const ignoredJournalUsers = (
process.env.SERVICENOW_IGNORE_USERS_JOURNAL ||
`${servicenowDefaultUser},admin.caoa`
)
.split(',')
.map((u) => String(u || '').trim().toLowerCase())
.filter(Boolean);
const normalizeServiceNowDateTime = (value) => {
if (!value) return null;
const asString = String(value).trim();
// Formato ISO/local: YYYY-MM-DD HH:mm:ss
const ymd = asString.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
if (ymd) {
return `${ymd[1]}-${ymd[2]}-${ymd[3]} ${ymd[4]}:${ymd[5]}:${ymd[6]}`;
}
// Formato SN comum com barra: DD/MM/YYYY HH:mm:ss
const dmy = asString.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
if (dmy) {
return `${dmy[3]}-${dmy[2]}-${dmy[1]} ${dmy[4]}:${dmy[5]}:${dmy[6]}`;
}
const parsed = new Date(asString);
if (Number.isNaN(parsed.getTime())) return null;
// Fallback preservando timestamp local sem converter para UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
};
const isAutomaticServiceNowTask = (entry) => {
if (!entry || entry.element !== 'work_notes') return false;
const content = String(entry.value || '');
return /registro alterado por:/i.test(content);
};
const fetchTicketsFromServiceNow = async (watermark) => {
try {
// A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date.
const watermarkDate = new Date(watermark);
// Ajusta para o fuso horário correto se necessário e formata para a query do SN.
const watermarkBRT = new Date(watermarkDate.getTime()); // Se precisar de ajuste de fuso, faça aqui.
const watermarkBRT = new Date(watermarkDate.getTime());
const formattedWatermark = watermarkBRT.toISOString().replace('T', ' ').substring(0, 19);
const fields = 'number,sys_id,short_description,state,description,caller_id,caller_id.email,caller_id.phone,caller_id.mobile_phone,location,opened_at,sys_updated_on';
const query = `assignment_group=1132c9e01bfad410e77eb9121b4bcbab^sys_updated_on>=${formattedWatermark}`;
const url = `${apiConfig.snTableIncidentConfig.baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const baseUrl = apiConfig.snTableIncidentConfig.baseUrl.replace(/\/$/, "");
const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${formattedWatermark}`;
const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const ticketsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
return ticketsResponse.data.result || [];
} catch (error) {
logError(error, 'SERVICE NOW Tickets');
throw error; // Lança o erro para que o controller não atualize a watermark
throw error;
}
}
const fetchRequestsFromServiceNow = async (watermark) => {
try {
// A tabela de requisições (sc_req_item) não possui o campo 'active'. A query deve ser apenas pela data.
// Construímos a query completa aqui, combinando o grupo de atribuição com a watermark.
const assignmentGroupSysId = '1132c9e01bfad410e77eb9121b4bcbab'; // Sys ID do grupo 'Sothis'
const query = `assignment_group=${assignmentGroupSysId}^sys_updated_on>=${watermark}`;
const fields = 'number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables';
// Garante que a URL base não tenha uma barra no final para evitar URL malformada.
const query = `assignment_group=${apiConfig.assignmentGroup.username}^sys_updated_on>=${watermark}`;
const fields ='number,request,opened_at,short_description,sys_id,assignment_group,opened_by,opened_by.display_value,opened_by.email,description,location,state,sys_updated_on,variables';
const baseUrl = apiConfig.snTableRequestConfig.baseUrl.replace(/\/$/, "");
//const url = `${baseUrl}?sysparm_query=${query}&sysparm_display_value=true&sysparm_fields=${fields}`;
const url = `${baseUrl}?sysparm_query=${encodeURIComponent(query)}&sysparm_display_value=true&sysparm_fields=${fields}`;
const requestsResponse = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
return requestsResponse.data.result || [];
} catch (error) {
logError(error, 'SERVICE NOW Requests');
throw error; // Lança o erro
throw error;
}
}
// Função para obter o sys_id de um ticket pela tabela de sincronização do banco de dados local
const fetchTicketLiveStatusFromServiceNow = async (sysId, ticketType) => {
try {
const baseUrl = ticketType === 'requisicao'
? apiConfig.snTableRequestConfig.baseUrl
: apiConfig.snTableIncidentConfig.baseUrl;
const url = `${baseUrl}/${sysId}?sysparm_display_value=true&sysparm_fields=state`;
const response = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
const result = response?.data?.result;
if (!result) return null;
if (typeof result.state === 'object') {
return result.state.display_value || result.state.value || null;
}
return result.state || null;
} catch (error) {
logError(error, `ERRO: Falha ao buscar status em tempo real no ServiceNow para sys_id ${sysId}`);
return null;
}
}
const fetchTicketCloserFromServiceNow = async (sysId, ticketType) => {
try {
const baseUrl = ticketType === 'requisicao'
? apiConfig.snTableRequestConfig.baseUrl
: apiConfig.snTableIncidentConfig.baseUrl;
const url = `${baseUrl}/${sysId}?sysparm_display_value=true&sysparm_fields=resolved_by,closed_by,sys_updated_by`;
const response = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
const result = response?.data?.result;
if (!result) return null;
const extractName = (field) => (typeof field === 'object' ? field?.display_value : field) || null;
// Incidente resolvido preenche resolved_by; fechado formalmente preenche closed_by.
// sc_req_item nao tem resolved_by - sys_updated_by e o unico campo garantido nos dois tipos.
return extractName(result.resolved_by) || extractName(result.closed_by) || extractName(result.sys_updated_by) || null;
} catch (error) {
logError(error, `ERRO: Falha ao buscar quem encerrou o ticket no ServiceNow para sys_id ${sysId}`);
return null;
}
};
const getTicketSysId = async (ticketId) => {
try {
const ticket = await TicketSnModel.findById(ticketId);
if (!ticket) {
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketSysId');
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketSysId');
return null;
}
return ticket.sys_id;
} catch (error) {
logError(error, '🚨Falha ao obter sys_id do ticket');
logError(error, 'ERRO: Falha ao obter sys_id do ticket');
return null;
}
}
// Função para obter o tipo de um ticket pela tabela de sincronização do banco de dados local
const getTicketTypeById = async (ticketId) => {
try {
const ticket = await TicketSnModel.findById(ticketId);
if (!ticket) {
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketTypeById');
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketTypeById');
return null;
}
const ticketType = ticket.tipo;
logInfo(`⚠️ Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
logInfo(`INFO: Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
return ticketType;
} catch (error) {
logError(error, '🚨Falha ao obter tipo do ticket');
logError(error, 'ERRO: Falha ao obter tipo do ticket');
return null;
}
}
// Função para obter o código de um comentário dentro de um ticket do ServiceNow
const getCommentId = async (sysId, comment) => {
try {
@ -97,19 +172,19 @@ const getCommentId = async (sysId, comment) => {
const data = response.data;
const comments = data.result;
// Procurar o comentário exato na lista retornada
// Procurar o comentario exato na lista retornada
for (const item of comments) {
if (item.value && item.value.includes(comment)) {
return item.sys_id; // Retorna o sys_id do comentário encontrado
return item.sys_id; // Retorna o sys_id do comentario encontrado
}
}
} catch (error) {
logError(error, '🚨Falha ao obter ID do comentário no ServiceNow');
logError(error, 'ERRO: Falha ao obter ID do comentario no ServiceNow');
return null;
}
}
// Função para criar um comentário em um ticket no ServiceNow
const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
try {
let url = '';
@ -136,42 +211,41 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
// Verificar se retorna status 200 para sucesso
if (response.status !== 200) {
logError(`❌ Falha ao criar comentário. Status: ${response.status}`, { sysId, ticketType });
logError(`ERRO: Falha ao criar comentario. Status: ${response.status}`, { sysId, ticketType });
return null;
}
// retornar id do comentário criado
// retornar id do comentario criado
const commentId = await getCommentId(sysId, comment);
if (!commentId) {
logError('❌ Comentário criado, mas não foi possível obter o ID do comentário', { sysId });
logError('ERRO: Comentario criado, mas nao foi possivel obter o ID do comentario', { sysId });
return null;
}
logInfo(`✅ Comentário criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
logInfo(`OK: Comentario criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
return commentId;
} catch (error) {
logError(error, '🚨Falha ao criar comentário no ServiceNow');
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
throw error;
}
}
// Função para criar um comentário
const syncCommentToServiceNow = async (ticketId, comment) => {
logInfo(`Iniciando sincronização do comentário para o ticket SN Ticket: ${ticketId}`);
logInfo(`Iniciando sincronizacao do comentario para o ticket SN Ticket: ${ticketId}`);
const snSysId = await getTicketSysId(ticketId);
const ticketType = await getTicketTypeById(ticketId);
if (!snSysId) {
logError(`❌ sys_id do ServiceNow não encontrado para o ticket SN Ticket: ${ticketId}`);
logError(`ERRO: sys_id do ServiceNow nao encontrado para o ticket SN Ticket: ${ticketId}`);
return null;
}
const snCommentId = await createCommentInServiceNow(ticketId,snSysId, comment, ticketType);
return snCommentId;
}
// Função que verifica se o comentário já existe no ServiceNow antes de criar
const existingCommentInServiceNow = async (ticketId, comment) => {
try {
const sys_id = await getTicketSysId(ticketId);
@ -179,18 +253,15 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
return commentExists;
} catch (error) {
logError(error, '🚨Falha ao verificar existência do comentário no ServiceNow');
logError(error, 'ERRO: Falha ao verificar existencia do comentario no ServiceNow');
return false;
}
}
// Função para buscar todos os comentários de tickets no ServiceNow a partir do ticket id no banco local
// Deve-se atentar para a paginação da API do ServiceNow
// para fins de teste, gerar um arquivo JSON com os comentários de um ticket específico
const fetchCommentsFromServiceNow = async (ticket) => {
try {
logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
logDebug(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const sys_id = ticket.sys_id;
@ -204,12 +275,13 @@ const fetchCommentsFromServiceNow = async (ticket) => {
while (true) {
page++;
const params = {
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^element=comments`,
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`,
sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id',
sysparm_display_value: true,
sysparm_limit: pageSize,
sysparm_offset: offset
};
logInfo(`Buscando página ${page} de comentários do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
logDebug(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, {
auth: apiConfig.servicenowAuthentication.auth,
params
@ -220,53 +292,77 @@ const fetchCommentsFromServiceNow = async (ticket) => {
if (!Array.isArray(comments) || comments.length === 0) {
break; // não há mais comentários
break; // nao ha mais comentarios
}
allComments.push(...comments);
if (comments.length < pageSize) {
break; // última página
break; // ultima pagina
}
// avançar para próxima página
// avancar para proxima pagina
offset += pageSize;
// proteção extra para evitar loop infinito em caso de comportamento inesperado da API
// protecao extra para evitar loop infinito em caso de comportamento inesperado da API
if (page >= internalMaxPages) {
logError(new Error('Max pages reached while paginating comments'), { ticketId, page });
logError(new Error('Max pages reached while paginating comments'), { sn_ticket_id: ticket.sn_ticket_id, page });
break;
}
}
// Traz apenas os tickets que não são do sys_created_by: "SOTHIS.CAOA"
const filteredComments = allComments.filter(comment => comment.sys_created_by !== 'SOTHIS.CAOA');
if (filteredComments.length === 0) {
logInfo(`Nenhum comentário encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
// Mantem comportamento legado para comments (evita loop da integracao).
// Em ambiente de teste, a regra pode ser desligada por env.
const filteredComments = allComments.filter((entry) => {
if (!shouldIgnoreDefaultUserJournal) {
return !isAutomaticServiceNowTask(entry);
}
const createdBy = String(entry.sys_created_by || '').trim().toLowerCase();
const ignoredUser = ignoredJournalUsers.includes(createdBy);
if (isAutomaticServiceNowTask(entry)) {
return false;
}
if (entry.element === 'comments') {
return !ignoredUser;
}
if (entry.element === 'work_notes') {
return !ignoredUser;
}
return true;
});
// Mantem ordem cronologica de criacao para preservar timeline no GLPI.
const orderedComments = filteredComments.sort((a, b) => {
const aTime = new Date(a.sys_created_on).getTime();
const bTime = new Date(b.sys_created_on).getTime();
return aTime - bTime;
});
if (orderedComments.length === 0) {
logDebug(`Nenhum comentario/worknote encontrado no Service Now para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
return [];
}
logInfo(`Encontrado ${filteredComments.length} comentários para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
logDebug(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
const commentsInserted = [];
// Insere cada comment no banco intermediario
for (const comment of filteredComments) {
for (const comment of orderedComments) {
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
if (existingComment) {
logInfo(`Comentário: ${comment.sys_id} já existe no banco de dados`, { step: 3 });
logDebug(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 });
continue;
} else {
const commentData = {
ticket_sync_id: ticket.id,
update_type: 'comment',
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
source_system: 'servicenow',
content: comment.value,
created_at: new Date(comment.sys_created_on),
author: "CAOA",
updated_at: new Date(),
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
author: comment.sys_created_by || 'ServiceNow',
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
source_id: comment.sys_id,
destiny_id: null
};
@ -276,9 +372,9 @@ const fetchCommentsFromServiceNow = async (ticket) => {
if (commentInserted.success) {
commentsInserted.push(commentData);
logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
logInfo(`OK: Comentario ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
} else {
logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
logError(`ERRO: Erro ao inserir comentario ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
return [];
}
}
@ -287,17 +383,16 @@ const fetchCommentsFromServiceNow = async (ticket) => {
} catch (error) {
logError(error, '🚨Falha ao buscar comentários do ServiceNow');
logError(error, 'ERRO: Falha ao buscar comentarios do ServiceNow');
return [];
}
};
// Adiciona no final do arquivo src/services/servicenowService.js
const { stripHTML } = require('../utils/commentSanitizer');
const fetchAndProcessClosedTicketsFromSN = async () => {
try {
logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...');
logInfo('INFO: Verificando status de tickets sincronizados no ServiceNow...');
// 1. Busca apenas os tickets que estão abertos e sincronizados no nosso banco.
// 1. Busca apenas os tickets que estao abertos e sincronizados no nosso banco.
const syncedTickets = await TicketSyncModel.getTicketsSynced();
if (syncedTickets.length === 0) {
@ -315,12 +410,12 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
await TicketSyncModel.updateStatusAndSourceLast(ticket.sn_ticket_id, 'synced', 'solved', 'SNOW');
} else if (ticketSnDetails && ticketSnDetails.status === 'Encerrado - Omitido') { // 'Encerrado - Omitido' mapeia para 'Closed' (7)
logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronização de fechamento...`);
logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronizacao de fechamento...`);
// Sanitiza o conteúdo da nota de fechamento antes de salvar.
// Sanitiza o conteudo da nota de fechamento antes de salvar.
const sanitizedContent = stripHTML(ticketSnDetails.close_notes || 'Chamado encerrado sem notas.');
// Salva a nota de fechamento no nosso banco intermediário.
// Salva a nota de fechamento no nosso banco intermediario.
await TicketUpdateModel.insert({
ticket_sync_id: ticket.id,
source_system: 'servicenow',
@ -337,36 +432,35 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
}
} catch (error) {
logError(error, '🚨Falha ao buscar chamados encerrados do ServiceNow');
logError(error, 'ERRO: Falha ao buscar chamados encerrados do ServiceNow');
}
};
/**
* Atualiza o status (state) de um ticket no ServiceNow (incidente ou requisição).
*/
const updateStatusInServiceNow = async (snTicketId, state) => {
// Mapeamento de status de string para código numérico do ServiceNow
const stateMap = {
'Aguardando Atendimento': '1', // New
'Em Atendimento': '2', // In Progress
'Em Espera': '3', // On Hold
'Encerrado': '6', // Resolved
'Encerrado - Omitido': '7' // Closed
};
const stateCode = stateMap[state] || state; // Usa o código mapeado ou o valor original se não encontrar
let sysId = null; // Declarar sysId fora do try para estar disponível no catch
let sysId = null; // Declarar sysId fora do try para estar disponivel no catch
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para atualização de status.`, 'updateStatusInServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizacao de status.`, 'updateStatusInServiceNow');
return null;
}
sysId = ticketSn.sys_id; // Atribuir valor
const ticketType = ticketSn.tipo;
// Mapeamento de status de string para codigo numerico do ServiceNow. incident e sc_req_item
// (requisicao) tem modelos de status diferentes - confirmado via teste real em dev que
// 'Em Espera' e '-5' para requisicao, e '3' para incidente (o mesmo codigo que e invalido/
// bloqueado por ACL do outro lado - ja aconteceu erro 403 em producao usando '3' numa requisicao).
const stateMap = {
'Aguardando Atendimento': '1', // New / Open
'Em Atendimento': '2', // In Progress / Work in Progress
'Em Espera': ticketType === 'requisicao' ? '-5' : '3', // On Hold
'Encerrado': '6', // Resolved
'Encerrado - Omitido': '7' // Closed
};
const stateCode = stateMap[state] || state; // Usa o codigo mapeado ou o valor original se nao encontrar
let url = '';
let payload = {};
@ -375,7 +469,7 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
payload = { state: stateCode, incident_state: stateCode }; // Envia state e incident_state
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = { state: stateCode }; // Requisições podem ter um fluxo diferente
payload = { state: stateCode }; // Requisicoes podem ter um fluxo diferente
} else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
@ -387,30 +481,34 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Atualiza o timestamp
if (response.status !== 200) {
logError(` Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
logError(`ERRO: Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
throw new Error(`Falha ao atualizar status no ServiceNow: ${response.status} - ${response.statusText}`);
}
logInfo(`✅ Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
// Validacao de leitura: o SN pode aceitar o PATCH (200) e ainda assim ignorar um codigo de
// estado invalido silenciosamente (ja aconteceu com o codigo antigo de 'Em Espera').
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
if (liveStatus !== state) {
logError(`Atualizacao de status no SN retornou 200 mas o status ao vivo ficou '${liveStatus}' em vez de '${state}'.`, { sysId, ticketType, state: stateCode });
return null;
}
logInfo(`OK: Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
return true;
} catch (error) {
logError(error, `🚨 Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
logError(error, `ERRO: Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
return null;
}
};
/**
* Resolve um ticket no ServiceNow (incidente ou requisição),
* atualizando o estado para 'Resolved' (6) e enviando as notas de resolução.
*/
const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
let sysId = null; // Declarar sysId fora do try
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para fechamento.`, 'closeTicketInServiceNow');
return null;
}
@ -419,9 +517,6 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
let url = ''; // Declarar url fora do if
let payload = {};
// O ID do usuário que está resolvendo o chamado. Pegue o sys_id do usuário da integração no SN.
// O nome do usuário que está resolvendo o chamado.
const resolvedBySysId = 'b131d6ed1b2ba510c5e163923b4bcb77'; // sys_id do usuário que resolveu
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
@ -432,21 +527,20 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
close_code: 'Closed/Resolved by caller',
close_notes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`
};
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
payload = {
state: '6',
work_notes: closeNotes || 'Requisição resolvida automaticamente via integração GLPI.'
resolved_by: resolvedBySysId,
resolved_at: new Date(resolvedAt).toISOString().slice(0, 19).replace('T', ' '),
close_notes: closeNotes,
work_notes: `Ticket Encerrado por Suporte Tecnico Sothis: ${closeNotes}`,
};
console.log(url);
console.log(payload);
} else {
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
@ -461,28 +555,34 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Corrigido para usar snTicketId
if (response.status !== 200) {
logError(` Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
logError(`ERRO: Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`);
}
logInfo(`✅ Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
// Validacao de leitura: o PATCH pode retornar 200 sem que o estado tenha realmente
// transicionado (regra de negocio/ACL do SN pode bloquear silenciosamente).
const liveStatus = await fetchTicketLiveStatusFromServiceNow(sysId, ticketType);
const closedForReal = liveStatus === 'Resolvido' || liveStatus === 'Encerrado' || liveStatus === 'Encerrado - Omitido';
if (!closedForReal) {
logError(`Fechamento no SN retornou 200 mas o status ao vivo nao confirmou. Status atual: '${liveStatus}'.`, { sysId, ticketType });
return false;
}
logInfo(`OK: Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
return true;
} catch (error) {
logError(error, `🚨 Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
logError(error, `ERRO: Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
return null;
}
};
/**
* Adiciona uma work_note a um ticket no ServiceNow sem alterar seu status.
*/
const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} não encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
return null;
}
@ -506,35 +606,245 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
logInfo(` Work note adicionada com sucesso ao ticket SN ${sysId}.`);
logInfo(`OK: Work note adicionada com sucesso ao ticket SN ${sysId}.`);
return true;
} catch (error) {
logError(error, `🚨 Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
logError(error, `ERRO: Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
return null;
}
};
const addCommentToServiceNow = async (snTicketId, comment) => {
let sysId = null;
let ticketType = null;
let url = '';
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para adicionar comentario.`, 'addCommentToServiceNow');
return null;
}
sysId = ticketSn.sys_id;
ticketType = ticketSn.tipo;
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { comments: comment };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao adicionar comentario no SN. Status: ${response.status}`, { snTicketId, sysId });
return null;
}
logInfo(`OK: Comentario adicionado com sucesso ao ticket SN ${sysId}.`);
return true;
} catch (error) {
const status = error?.response?.status;
if (status === 403) {
logError('ERRO: 403 ao adicionar comentario no SN (ACL/permissao/regra do estado).', {
snTicketId,
sysId,
ticketType,
endpoint: url
});
return null;
}
logError(error, `ERRO: Falha ao adicionar comentario ao ticket SN (sysId: ${sysId})`);
return null;
}
};
const updateExternalTicketInServiceNow = async (snTicketId, glpiTicketId) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizar Ticket Externo.`, 'updateExternalTicketInServiceNow');
return false;
}
sysId = ticketSn.sys_id;
const ticketType = ticketSn.tipo;
let url = '';
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const payload = { [externalTicketField]: String(glpiTicketId) };
const response = await axios.patch(url, payload, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
if (response.status !== 200) {
logError(`Falha ao atualizar Ticket Externo no SN. Status: ${response.status}`, { snTicketId, glpiTicketId, externalTicketField });
return false;
}
// Validacao de leitura: alguns campos customizados podem retornar 200 e nao persistir por ACL/schema.
const verifyResp = await axios.get(`${url}?sysparm_fields=${externalTicketField}&sysparm_display_value=true`, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Accept': 'application/json' }
});
const result = verifyResp?.data?.result || {};
const rawField = result[externalTicketField];
const persistedValue = typeof rawField === 'object'
? (rawField.value || rawField.display_value || '')
: (rawField || '');
if (String(persistedValue) !== String(glpiTicketId)) {
logError(`Ticket Externo nao persistiu no SN (campo ${externalTicketField}).`, {
snTicketId,
sysId,
expected: String(glpiTicketId),
got: String(persistedValue)
});
return false;
}
logInfo(`OK: Ticket Externo confirmado no SN (${externalTicketField}=${glpiTicketId}).`, { snTicketId, sysId });
return true;
} catch (error) {
logError(error, `ERRO: Falha ao atualizar Ticket Externo no SN (sysId: ${sysId})`);
return false;
}
};
const reassignTicketInServiceNow = async (snTicketId, groupSysId) => {
let sysId = null;
try {
const ticketSn = await TicketSnModel.findById(snTicketId);
if (!ticketSn) {
logError(`Ticket SN com ID ${snTicketId} nao encontrado para reatribuicao de grupo.`, 'reassignTicketInServiceNow');
return false;
}
sysId = ticketSn.sys_id;
const ticketType = ticketSn.tipo;
let url = '';
if (ticketType === 'incidente') {
url = `${apiConfig.snTableIncidentConfig.baseUrl}/${sysId}`;
} else if (ticketType === 'requisicao') {
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
} else {
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
}
const response = await axios.patch(url, { assignment_group: groupSysId }, {
auth: apiConfig.servicenowAuthentication.auth,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }
});
if (response.status !== 200) {
logError(`Falha ao reatribuir grupo no SN. Status: ${response.status}`, { snTicketId, groupSysId });
return false;
}
logInfo(`OK: Ticket SN ${snTicketId} reatribuido para grupo ${groupSysId}.`, { snTicketId, sysId });
return true;
} catch (error) {
logError(error, `ERRO: Falha ao reatribuir grupo no SN (sysId: ${sysId})`);
return false;
}
};
const queueExternalTicketLinkRetry = async (ticketSyncId, snTicketId, glpiTicketId, shouldMarkDone = false) => {
const sourceId = `external_link:${snTicketId}:${glpiTicketId}`;
const payload = JSON.stringify({ sn_ticket_id: snTicketId, glpi_ticket_id: glpiTicketId, field: externalTicketField });
await TicketUpdateModel.insert({
ticket_sync_id: ticketSyncId,
update_type: 'external_link',
source_system: 'sync',
content: payload,
author: 'integration',
created_at: new Date(),
updated_at: new Date(),
source_id: sourceId,
destiny_id: shouldMarkDone ? 'done' : null
});
};
const processPendingExternalTicketLinks = async () => {
try {
const pendings = await TicketUpdateModel.getPendingBySourcePrefix('external_link:');
if (!pendings.length) return;
for (const pending of pendings) {
try {
const payload = JSON.parse(pending.content || '{}');
const snTicketId = payload.sn_ticket_id;
const glpiTicketId = payload.glpi_ticket_id;
if (!snTicketId || !glpiTicketId) {
logError(`Payload invalido para retry de Ticket Externo. update_id=${pending.id}`);
continue;
}
const ok = await updateExternalTicketInServiceNow(snTicketId, glpiTicketId);
if (ok) {
await TicketUpdateModel.updateDestinyId(pending.source_id, 'done');
}
} catch (error) {
logError(error, `Falha ao processar retry de Ticket Externo. update_id=${pending.id}`);
}
}
} catch (error) {
logError(error, 'ERRO: Falha geral no retry de Ticket Externo');
}
};
/**
* Busca o valor de uma variável de catálogo (sc_item_option) para um Request Item (RITM) específico.
* Busca o valor de uma variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico.
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
* @param {string} variableName - O nome interno (name) da variável de catálogo.
* @returns {Promise<string|null>} O valor da variável ou null se não encontrada/erro.
* @param {string} variableName - O nome interno (name) da variavel de catalogo.
* @returns {Promise<string|null>} O valor da variavel ou null se nao encontrada/erro.
*/
const fetchScItemOptionValue = async (ritmSysId, variableName) => {
try {
const baseUrl = apiConfig.snScItemOptionConfig.baseUrl.replace(/\/$/, "");
const url = `${baseUrl}?sysparm_query=request_item=${ritmSysId}^sc_item_option.item_option_new.question_textINPlease justify the need,Telephone,Select what you want&sysparm_fields=sc_item_option.item_option_new.question_text,sc_item_option.value`;
const url = `${baseUrl}?sysparm_query=request_item=${ritmSysId}^sc_item_option.item_option_new.question_textINPlease justify the need,Telephone,Select what you want&sysparm_fields=sc_item_option.item_option_new.question_text,sc_item_option.item_option_new.name,sc_item_option.value`;
const response = await axios.get(url, {
auth: apiConfig.servicenowAuthentication.auth
});
const result = response.data.result;
return (result && result.length > 0) ? result : null;
const result = response.data.result || [];
if (result.length === 0) return null;
const normalize = (value) => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'object') {
return value.value || value.display_value || '';
}
return String(value);
};
return result.map((item) => ({
question_text: normalize(item['sc_item_option.item_option_new.question_text']),
variable_name: normalize(item['sc_item_option.item_option_new.name']),
value: normalize(item['sc_item_option.value'])
}));
} catch (error) {
logError(error, `🚨 Falha ao buscar valor da variável '${variableName}' para RITM ${ritmSysId}`);
logError(error, `ERRO: Falha ao buscar valor da variavel '${variableName}' para RITM ${ritmSysId}`);
return null;
}
};
@ -542,15 +852,40 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => {
module.exports = {
fetchTicketsFromServiceNow,
fetchRequestsFromServiceNow,
fetchTicketLiveStatusFromServiceNow,
fetchTicketCloserFromServiceNow,
getTicketSysId,
getTicketTypeById,
createCommentInServiceNow,
getCommentId,
existingCommentInServiceNow,
syncCommentToServiceNow,
fetchCommentsFromServiceNow,
fetchAndProcessClosedTicketsFromSN,
closeTicketInServiceNow, // Adicionei a vírgula aqui
updateStatusInServiceNow, // Adicionei a vírgula aqui
addWorkNoteToServiceNow, // Adicionei a vírgula aqui
fetchScItemOptionValue // Nova função
closeTicketInServiceNow,
updateStatusInServiceNow,
addWorkNoteToServiceNow,
addCommentToServiceNow,
fetchScItemOptionValue,
updateExternalTicketInServiceNow,
reassignTicketInServiceNow,
queueExternalTicketLinkRetry,
processPendingExternalTicketLinks
};
/**
* @module servicenowService
* @description Este servico encapsula todas as interacoes com a API REST do ServiceNow.
* Ele e responsavel por buscar, criar e atualizar dados na plataforma ServiceNow.
*
* Principais Funcionalidades:
* - **Busca de Tickets**: `fetchTicketsFromServiceNow` e `fetchRequestsFromServiceNow` buscam incidentes e requisicoes, respectivamente, utilizando uma marca d'agua para obter apenas os registros novos ou atualizados.
* - **Busca de Comentarios (`fetchCommentsFromServiceNow`)**: Busca todos os comentarios (journal entries) de um ticket, com logica de paginacao para lidar com grandes volumes de dados. Ele tambem filtra os comentarios gerados pelo proprio sistema de integracao para evitar loops.
* - **Criacao de Comentarios**: `createCommentInServiceNow` e a funcao wrapper `syncCommentToServiceNow` adicionam comentarios (work notes/comments) a um ticket existente no ServiceNow.
* - **Atualizacao de Status**: `updateStatusInServiceNow` modifica o estado de um ticket (ex: para 'Em Atendimento').
* - **Fechamento de Tickets (`closeTicketInServiceNow`)**: Altera o estado de um ticket para "Resolvido", preenchendo os campos obrigatorios como notas de fechamento e data de resolucao.
* - **Adicao de Notas (`addWorkNoteToServiceNow`)**: Adiciona uma nota de trabalho a um ticket sem alterar seu status, util para registrar informacoes ou avisos.
* - **Busca de Variaveis de Catalogo (`fetchScItemOptionValue`)**: Funcao especializada para buscar valores de variaveis em itens de requisicao (RITMs), usada para enriquecer a descricao de tickets.
*
* Todas as funcoes de interacao com a API sao envolvidas em blocos try-catch com logging detalhado para facilitar o monitoramento e a depuracao.
*/

View File

@ -1,13 +1,8 @@
// src/utils/commentSanitizer.js
const { logInfo, logWarning } = require('./logger');
const { logDebug, logWarning } = require('./logger');
/**
* Remove HTML tags e converte entidades HTML - CORRIGIDO
*/
const stripHTML = (html) => {
if (!html) return '';
// PRIMEIRO: converte entidades HTML para caracteres normais
let cleaned = html
.replace(/&#60;/g, '<') // &#60; para <
.replace(/&#62;/g, '>') // &#62; para >
@ -18,7 +13,6 @@ const stripHTML = (html) => {
.replace(/&amp;/g, '&') // &amp; para &
.replace(/&quot;/g, '"'); // &quot; para "
// DEPOIS: remove tags HTML
cleaned = cleaned
.replace(/<br\s*\/?>/gi, '\n') // <br> para quebra de linha
.replace(/<p>/gi, '') // Remove <p>
@ -39,33 +33,24 @@ const stripHTML = (html) => {
return cleaned;
};
/**
* Detecta e trata imagens do GLPI
*/
const handleImages = (content) => {
const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi;
const hasImages = imgRegex.test(content);
if (hasImages) {
logWarning('📷 Imagem detectada no comentário GLPI');
// Remove toda a tag de imagem mas mantém o texto around
return content.replace(/<a[^>]*>.*?<img[^>]*>.*?<\/a>/gi, '[IMAGEM ANEXA NO GLPI]');
}
return content;
};
/**
* Limpa metadados JSON do GLPI
*/
const cleanJSONMetadata = (content) => {
if (typeof content === 'string' && content.includes('{id=') && content.includes('content=')) {
try {
// Extrai o conteúdo do campo content= do objeto
const contentMatch = content.match(/content=([^,]+),/);
if (contentMatch && contentMatch[1]) {
let extracted = contentMatch[1].trim();
// Remove aspas se existirem
extracted = extracted.replace(/^['"]|['"]$/g, '');
return extracted;
}
@ -76,29 +61,22 @@ const cleanJSONMetadata = (content) => {
return content;
};
/**
* Função principal de sanitização
*/
const sanitizeGLPIComment = (commentObj) => {
if (!commentObj || typeof commentObj !== 'object') {
logWarning('Comentário inválido recebido', { comment: commentObj });
return '';
}
// Pega o conteúdo do objeto comment
let content = commentObj.content || '';
// Se for string vazia, retorna vazio
if (!content) return '';
// Aplica as limpezas em sequência
content = cleanJSONMetadata(content.toString());
content = handleImages(content);
content = stripHTML(content);
// Log para debugging
if (content !== commentObj.content) {
logInfo('🔧 Comentário sanitizado', {
logDebug('🔧 Comentário sanitizado', {
original: commentObj.content.substring(0, 100) + '...',
cleaned: content.substring(0, 100) + '...'
});
@ -108,17 +86,16 @@ const sanitizeGLPIComment = (commentObj) => {
};
/**
* Esse módulo fornece funções para sanitizar comentários importados do GLPI.
* Ele remove tags HTML, trata imagens e limpa metadados JSON específicos do GLPI.
* O objetivo é garantir que os comentários sejam legíveis e seguros para exibição.
*
* Funções principais:
* - sanitizeGLPIComment: Função principal que aplica todas as sanitizações.
* - stripHTML: Remove tags HTML e converte algumas entidades para texto simples.
* - handleImages: Detecta imagens e substitui por um marcador de imagem.
* - cleanJSONMetadata: Remove metadados JSON do GLPI, extraindo apenas o conteúdo relevante.
*
* Logs são gerados para ajudar no debugging e monitoramento do processo de sanitização.
* @module commentSanitizer
* @description Este módulo utilitário é especializado em limpar e formatar o conteúdo dos comentários (follow-ups) vindos do GLPI.
* O GLPI armazena comentários em formato HTML, muitas vezes com metadados e formatações que não são desejáveis no ServiceNow.
* O objetivo é converter esse conteúdo em texto puro e legível.
*
* Funções:
* - `sanitizeGLPIComment(commentObj)`: A função principal que orquestra o processo de limpeza.
* - `stripHTML(html)`: Remove a maioria das tags HTML, convertendo algumas (como `<br>`, `<p>`, `<strong>`) em marcações de texto simples (quebras de linha, asteriscos).
* - `handleImages(content)`: Detecta a presença de imagens nos comentários do GLPI e as substitui por um texto placeholder, como "[IMAGEM ANEXA NO GLPI]", que as imagens não podem ser transferidas diretamente.
* - `cleanJSONMetadata(content)`: Remove metadados em formato de string JSON que o GLPI às vezes insere no campo de conteúdo, extraindo apenas o texto real do comentário.
*/
module.exports = {
@ -127,6 +104,3 @@ module.exports = {
handleImages,
cleanJSONMetadata
};

View File

@ -1,5 +1,6 @@
const winston = require('winston');
const path = require('path');
require('winston-daily-rotate-file');
const fs = require('fs');
// verifica se a pasta de logs existe, se não, cria
@ -8,40 +9,69 @@ if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const isProduction = process.env.NODE_ENV === 'production';
const logLevel = process.env.LOG_LEVEL || 'info';
const logToConsole = process.env.LOG_TO_CONSOLE
? process.env.LOG_TO_CONSOLE === 'true'
: !isProduction;
// Configuração do logger com winston
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.errors({ stack: true }), // ← Mostra stack trace de erros
winston.format.json()
),
level: logLevel,
format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true })),
transports: [
// Log geral da aplicação
new winston.transports.File({
filename: path.join(logsDir, 'app.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
new winston.transports.DailyRotateFile({
filename: path.join(logsDir, 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '5m',
maxFiles: '10d',
format: winston.format.combine(
winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
}
return logMessage;
})
),
}),
// Log de erros
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
new winston.transports.DailyRotateFile({
filename: path.join(logsDir, 'error-%DATE%.log'),
level: 'error',
maxsize: 5242880,
maxFiles: 3
}),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '5m',
maxFiles: '10d',
format: winston.format.combine(
winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
}
return logMessage;
})
),
})
],
});
// Log no console em desenvolvimento
if (process.env.NODE_ENV !== 'production') {
// Log no console (controlado por variavel de ambiente)
if (logToConsole) {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, stack }) => {
return `${timestamp} [${level}]: ${stack || message}`;
winston.format.printf((info) => {
const { timestamp, level, message, stack, ...meta } = info;
let logMessage = `${timestamp} [${level}]: ${message}${stack ? `\n${stack}` : ''}`;
if (Object.keys(meta).length) {
logMessage += ` ${JSON.stringify(meta, null, 2)}`;
}
return logMessage;
})
)
}));
@ -64,9 +94,15 @@ const logWarning = (message, meta = {}) => {
logger.warn(message, meta);
};
// Log de sincronização específico
const logDebug = (message, meta = {}) => {
logger.debug(message, meta);
};
// Log de sincronização específico. Fica em debug: por causa da margem de seguranca do
// watermark, esse log dispara toda vez que ha qualquer ticket "quente" no lote, mesmo sem
// mudanca real de campo - o sinal de mudanca de verdade e logado no chamador.
const logSync = (service, count, type) => {
logger.info(`SYNC: ${service} - ${count} ${type} sincronizados`, {
logger.debug(`SYNC: ${service} - ${count} ${type} sincronizados`, {
service,
count,
type
@ -78,5 +114,6 @@ module.exports = {
logError,
logInfo,
logWarning,
logSync
logDebug,
logSync,
};