Compare commits
No commits in common. "master" and "v1.0" have entirely different histories.
43
.env
Normal file
43
.env
Normal file
@ -0,0 +1,43 @@
|
||||
# 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
85
.env.example
@ -1,85 +0,0 @@
|
||||
# ===================================================================
|
||||
# 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
|
||||
@ -1,131 +0,0 @@
|
||||
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
5
.gitignore
vendored
@ -1,11 +1,8 @@
|
||||
node_modules/
|
||||
logs/
|
||||
logs/*.log
|
||||
config/*.json
|
||||
.DS_Store
|
||||
*.tmp
|
||||
servicenow_requests.json
|
||||
servicenow_tickets.json
|
||||
location_mapping.log
|
||||
.env*
|
||||
!.env.example
|
||||
docs/checklist.md
|
||||
160
README.md
160
README.md
@ -1,138 +1,80 @@
|
||||
# Sistema de Integracao ServiceNow <-> GLPI
|
||||
# servicenow-glpi-sync/README.md
|
||||
|
||||
Middleware Node.js para integrar chamados entre ServiceNow e GLPI, usando PostgreSQL como banco intermediario e acesso ao banco do GLPI.
|
||||
# ServiceNow and GLPI Sync
|
||||
|
||||
## Estado atual
|
||||
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.
|
||||
|
||||
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.
|
||||
## Table of Contents
|
||||
|
||||
## Fluxo do ciclo
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Endpoints](#endpoints)
|
||||
- [Logging](#logging)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
Executado pelo `main()`:
|
||||
## Prerequisites
|
||||
|
||||
1. `processErrorController`
|
||||
2. `processTicketsController`
|
||||
3. `processTicketLifecycleController` (quando `ENABLE_GLPI_CLOSE_CRON=true`)
|
||||
4. `processCommentsController`
|
||||
- Node.js (version 14 or higher)
|
||||
- npm (Node Package Manager)
|
||||
- PostgreSQL or MySQL database
|
||||
- Access to ServiceNow and GLPI APIs
|
||||
|
||||
## Documentacao detalhada
|
||||
## Installation
|
||||
|
||||
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)
|
||||
1. Clone the repository:
|
||||
|
||||
## Variaveis de ambiente importantes
|
||||
```
|
||||
git clone https://github.com/yourusername/servicenow-glpi-sync.git
|
||||
cd servicenow-glpi-sync
|
||||
```
|
||||
|
||||
### Feature flags
|
||||
2. Install the dependencies:
|
||||
|
||||
1. `ENABLE_GLPI_CLOSE_CRON`
|
||||
- `true`: liga o motor de regras de status/fechamento/reabertura (`processTicketLifecycleController`)
|
||||
- `false`: desliga o motor inteiro
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### ServiceNow
|
||||
3. Copy the `.env.example` file to `.env` and fill in the required environment variables:
|
||||
|
||||
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`)
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### GLPI e banco intermediario
|
||||
## Configuration
|
||||
|
||||
1. `GLPI_DB_*`
|
||||
2. `SNGLPI_DB_*`
|
||||
3. `GLPI_OOS_SOLUTION_TYPE_ID`
|
||||
- Update the `.env` file with your ServiceNow and GLPI API credentials, database connection details, and any other necessary configurations.
|
||||
|
||||
## Desenvolvimento
|
||||
## Running the Application
|
||||
|
||||
To start the application, run:
|
||||
|
||||
1. Instalar dependencias:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
2. Criar env:
|
||||
```bash
|
||||
copy .env.example .env.development
|
||||
```
|
||||
3. Executar:
|
||||
```bash
|
||||
npm run dev
|
||||
node server.js
|
||||
```
|
||||
|
||||
## Producao
|
||||
The application will listen on the port specified in the `.env` file.
|
||||
|
||||
### Deploy manual na VM
|
||||
## Endpoints
|
||||
|
||||
```bash
|
||||
git pull origin master
|
||||
npm ci --omit=dev
|
||||
pm2 start ecosystem.config.js --env production
|
||||
pm2 save
|
||||
```
|
||||
- `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.
|
||||
|
||||
Se o processo ja estiver rodando:
|
||||
## Logging
|
||||
|
||||
```bash
|
||||
pm2 restart sn-glpi-sync-cron --update-env
|
||||
```
|
||||
The application uses Winston for logging. Logs are stored in the `logs` directory:
|
||||
|
||||
### Deploy via Gitea Actions
|
||||
- `app.log` - Logs application events.
|
||||
- `error.log` - Logs errors that occur during execution.
|
||||
|
||||
O workflow `.gitea/workflows/deploy-production.yml` roda no runner com rotulo `vm-prod`.
|
||||
## Contributing
|
||||
|
||||
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:
|
||||
Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes.
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 logs sn-glpi-sync-cron
|
||||
```
|
||||
## License
|
||||
|
||||
### 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.
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
@ -1,4 +1,4 @@
|
||||
//config/apiConfig.js
|
||||
require('dotenv').config();
|
||||
|
||||
const glpiDbConfig = {
|
||||
client: process.env.GLPI_DB_TYPE || 'mysql',
|
||||
@ -24,6 +24,11 @@ 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,
|
||||
}
|
||||
@ -40,18 +45,14 @@ 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,
|
||||
assignmentGroup
|
||||
snScItemOptionConfig
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
const { logInfo, logError } = require('../src/utils/logger');
|
||||
require('dotenv').config();
|
||||
|
||||
// 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();
|
||||
logInfo('Connection to the database has been established successfully.');
|
||||
console.log('Connection to the database has been established successfully.');
|
||||
} catch (error) {
|
||||
logError('Unable to connect to the database:', error);
|
||||
console.error('Unable to connect to the database:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
44
cron.js
44
cron.js
@ -1,44 +0,0 @@
|
||||
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
165
docs/RODAMP.MD
@ -1,165 +0,0 @@
|
||||
# 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.
|
||||
@ -1,153 +0,0 @@
|
||||
# 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`.
|
||||
@ -1,89 +0,0 @@
|
||||
# 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.
|
||||
@ -1,103 +0,0 @@
|
||||
# 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) |
|
||||
@ -1,228 +0,0 @@
|
||||
# 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.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
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
499
package-lock.json
generated
@ -13,14 +13,8 @@
|
||||
"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-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"nodemon": "^3.1.4"
|
||||
"winston": "^3.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
@ -62,20 +56,6 @@
|
||||
"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",
|
||||
@ -100,26 +80,6 @@
|
||||
"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",
|
||||
@ -140,30 +100,6 @@
|
||||
"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",
|
||||
@ -202,31 +138,6 @@
|
||||
"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",
|
||||
@ -272,13 +183,6 @@
|
||||
"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",
|
||||
@ -318,39 +222,6 @@
|
||||
"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",
|
||||
@ -523,28 +394,6 @@
|
||||
"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",
|
||||
@ -606,21 +455,6 @@
|
||||
"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",
|
||||
@ -676,19 +510,6 @@
|
||||
"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",
|
||||
@ -701,16 +522,6 @@
|
||||
"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",
|
||||
@ -772,13 +583,6 @@
|
||||
"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",
|
||||
@ -800,52 +604,6 @@
|
||||
"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",
|
||||
@ -870,13 +628,6 @@
|
||||
"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",
|
||||
@ -981,28 +732,6 @@
|
||||
"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",
|
||||
@ -1066,63 +795,6 @@
|
||||
"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",
|
||||
@ -1174,16 +846,6 @@
|
||||
"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",
|
||||
@ -1283,19 +945,6 @@
|
||||
"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",
|
||||
@ -1348,13 +997,6 @@
|
||||
"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",
|
||||
@ -1408,19 +1050,6 @@
|
||||
"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",
|
||||
@ -1472,19 +1101,6 @@
|
||||
"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",
|
||||
@ -1533,29 +1149,6 @@
|
||||
"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",
|
||||
@ -1637,19 +1230,6 @@
|
||||
"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",
|
||||
@ -1695,38 +1275,12 @@
|
||||
"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",
|
||||
@ -1736,16 +1290,6 @@
|
||||
"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",
|
||||
@ -1769,13 +1313,6 @@
|
||||
"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",
|
||||
@ -1800,22 +1337,6 @@
|
||||
"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",
|
||||
@ -1838,24 +1359,6 @@
|
||||
"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",
|
||||
|
||||
16
package.json
16
package.json
@ -2,12 +2,10 @@
|
||||
"name": "servicenow-glpi-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "A Node.js application for integrating ServiceNow and GLPI.",
|
||||
"main": "cron.js",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"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",
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"No tests specified\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -15,14 +13,8 @@
|
||||
"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-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"nodemon": "^3.1.4"
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"author": "Your Name",
|
||||
"license": "MIT"
|
||||
|
||||
370
servicenow_comments_ticket_626.json
Normal file
370
servicenow_comments_ticket_626.json
Normal file
@ -0,0 +1,370 @@
|
||||
[
|
||||
{
|
||||
"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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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": "<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": "1b1dd182fbd8fe50ef1ffe316eefdc5f",
|
||||
"sys_created_on": "2025-10-01 15:11:30",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "2a5d15c2fb187e1023e5f8d65eefdce3",
|
||||
"sys_created_on": "2025-10-01 15:12:31",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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=<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>, 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": "<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": "4a3b14eafbd87290ef1ffe316eefdcc1",
|
||||
"sys_created_on": "2025-10-02 23:40:35",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "{id=47145, content=<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>, 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=<p>Comentário teste, para validar funcionamento do Midware</p>, 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": "<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": "5f16ce5afb1cbe1023e5f8d65eefdcea",
|
||||
"sys_created_on": "2025-10-02 12:49:07",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "6716829afb1cbe1023e5f8d65eefdc5e",
|
||||
"sys_created_on": "2025-10-02 12:49:08",
|
||||
"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": "724825cefb187e1023e5f8d65eefdcd3",
|
||||
"sys_created_on": "2025-10-01 16:00:20",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "75e3d242fbdcfe50ef1ffe316eefdc6c",
|
||||
"sys_created_on": "2025-10-01 19:10:53",
|
||||
"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": "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. </p>\r\n<p>Testando 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> </p>\r\n<div>Testando DIV</div>\r\n<div> </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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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=<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>, 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<p>Vai ter como sim, podemos ver</p>\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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "a25dd582fbd8fe50ef1ffe316eefdce8",
|
||||
"sys_created_on": "2025-10-01 15:12:33",
|
||||
"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": "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": "<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": "ad2c3146fb987e1023e5f8d65eefdc72",
|
||||
"sys_created_on": "2025-10-01 17:27:05",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "c168ad8efbd8fe50ef1ffe316eefdca2",
|
||||
"sys_created_on": "2025-10-01 16:00:47",
|
||||
"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": "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. </p>\r\n<p>Testando 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> </p>\r\n<div>Testando DIV</div>\r\n<div> </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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "cfe25202fbdcfe50ef1ffe316eefdc58",
|
||||
"sys_created_on": "2025-10-01 19:06:36",
|
||||
"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": "d18ad4aafbd87290ef1ffe316eefdccc",
|
||||
"sys_created_on": "2025-10-02 23:37:31",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "{id=47144, content=<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>, 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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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=<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>, 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": "<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": "eaecfd46fb987e1023e5f8d65eefdcab",
|
||||
"sys_created_on": "2025-10-01 17:30:27",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"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> </p>\r\n<p> </p>\r\n<p>PARA <br><br><br><br><br>VERIFICAR</p>\r\n<p> </p>\r\n<p>HTML<br><br><br>NO </p>\r\n<p> </p>\r\n<p> </p>\r\n<p>COMENTARIO<br><br><br></p>\r\n<p>Testando 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> </p>\r\n<div>Testando DIV</div>\r\n<div> </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> </p>\r\n<p> </p>\r\n<p>zcx</p>\r\n<p>adsgvsdbf</p>\r\n<p> </p>\r\n<p> </p>\r\n<p> </p>\r\n<p> </p>\r\n<p>teste</p>\r\n<p> </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 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> </p>\r\n<div>Testando DIV</div>\r\n<div> </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": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
},
|
||||
{
|
||||
"sys_id": "fde3decefbd87e1023e5f8d65eefdcc8",
|
||||
"sys_created_on": "2025-10-01 19:10:51",
|
||||
"element_id": "493386721b2c9a90c5e163923b4bcb4a",
|
||||
"value": "<p>Vai ter como sim, podemos ver</p>",
|
||||
"sys_created_by": "SOTHIS.CAOA",
|
||||
"element": "comments"
|
||||
}
|
||||
]
|
||||
46
src/app.js
46
src/app.js
@ -1,49 +1,27 @@
|
||||
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) });
|
||||
|
||||
// src/app.js
|
||||
require('dotenv').config();
|
||||
const { processTicketsController } = require('./controllers/processTicketsController');
|
||||
const { processCommentsController } = require('./controllers/processCommentsController');
|
||||
const { processErrorController } = require('./controllers/processErrorController');
|
||||
const { processTicketLifecycleController } = require('./controllers/processTicketLifecycleController');
|
||||
const { processStatusAndClosureController } = require('./controllers/processStatusController');
|
||||
const { logInfo } = require('./utils/logger');
|
||||
|
||||
logInfo(`Ambiente carregado: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
logInfo('Aplicacao iniciada', {
|
||||
logInfo('Aplicação iniciada', {
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
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.');
|
||||
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 aplicacao:', error);
|
||||
logInfo('Erro na aplicação:', error);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
|
||||
|
||||
|
||||
main();
|
||||
@ -1,33 +1,20 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,44 +1,40 @@
|
||||
// src/controllers/processErrorController.js
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
const { logInfo, logError, logWarning, logDebug } = require('../utils/logger');
|
||||
|
||||
const MAX_ERROR_RETRIES = Number(process.env.MAX_ERROR_RETRIES || 5);
|
||||
const { logInfo, logError, logWarning } = require('../utils/logger');
|
||||
|
||||
const processErrorController = async () => {
|
||||
logDebug('Iniciando verificação de tickets com erro...');
|
||||
logInfo('Iniciando verificação de tickets com erro...');
|
||||
|
||||
try {
|
||||
const errorTickets = await TicketSyncModel.getTicketsInErrorState();
|
||||
|
||||
if (errorTickets.length === 0) {
|
||||
logDebug('Nenhum ticket com erro encontrado para reprocessamento.');
|
||||
logInfo('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}, tentativa ${retryCount}/${MAX_ERROR_RETRIES}).`);
|
||||
logInfo(`Ticket SN ID ${ticket.sn_ticket_id} resetado para reprocessamento (SN: ${newSnStatus}, GLPI: ${newGlpiStatus}).`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@ -49,11 +45,3 @@ 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.
|
||||
*/
|
||||
|
||||
155
src/controllers/processStatusController.js
Normal file
155
src/controllers/processStatusController.js
Normal file
@ -0,0 +1,155 @@
|
||||
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
|
||||
};
|
||||
@ -2,63 +2,70 @@ 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, logDebug } = require('../utils/logger');
|
||||
const { logInfo, logError, logSync } = require('../utils/logger');
|
||||
const e = require('express');
|
||||
|
||||
/**
|
||||
* Processa e salva um lote de tickets (incidentes ou requisicoes).
|
||||
* Processa e salva um lote de tickets (incidentes ou requisições).
|
||||
* @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, mes-1, dia, hora, minuto, segundo)
|
||||
// Formato: new Date(ano, mês-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) {
|
||||
logDebug(`Nenhum ticket do tipo '${type}' para processar.`);
|
||||
return '';
|
||||
logInfo(`Nenhum ticket do tipo '${type}' para processar.`);
|
||||
return ''; // Retorna string vazia se não houver tickets
|
||||
}
|
||||
|
||||
logInfo(`INFO: Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
|
||||
let latestUpdateTimestamp = new Date(0);
|
||||
logInfo(`💾 Iniciando salvamento de ${tickets.length} ${type}s no banco...`);
|
||||
let latestUpdateTimestamp = new Date(0); // Inicia com a data mínima (epoch)
|
||||
for (const ticket of tickets) {
|
||||
try {
|
||||
if (type === 'requisicao') {
|
||||
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
|
||||
logInfo(`Buscando variaveis de catalogo para requisicao ${ticketNumber}.`);
|
||||
// --- 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.
|
||||
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'];
|
||||
|
||||
(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 (question === 'Select what you want') vars.whatYouWant = value;
|
||||
if (question === 'Please justify the need') vars.justification = value;
|
||||
if (question === 'Telephone') vars.telephone = value;
|
||||
});
|
||||
|
||||
ticket.justificativa = vars.justification || null;
|
||||
if (vars.telephone) {
|
||||
|
||||
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.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 ---
|
||||
|
||||
const ticketNumber = typeof ticket.number === 'object' ? ticket.number?.value : ticket.number;
|
||||
const existingTicketSn = await TicketSnModel.findByTicketNumber(ticketNumber);
|
||||
const oldStatus = existingTicketSn?.status || null;
|
||||
|
||||
// 1. Salva o ticket na tabela 'tickets_sn'
|
||||
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 = {
|
||||
@ -69,29 +76,31 @@ 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';
|
||||
dataSync.glpi_sync_status = 'ignored'; // Ignora no GLPI se já veio fechado do SN
|
||||
} else {
|
||||
dataSync.sn_sync_status = 'collected';
|
||||
dataSync.glpi_sync_status = 'pending_check';
|
||||
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.source_last = 'SNOW';
|
||||
dataSync.source_last = 'SNOW'; // A última atualização veio do ServiceNow
|
||||
|
||||
// 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) {
|
||||
if (oldStatus !== ticketStatus) {
|
||||
// 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 (status '${oldStatus}' -> '${ticketStatus}'). 'source_last' definido como 'SNOW'.`);
|
||||
logInfo(`Ticket ${ticket.number} atualizado pelo ServiceNow. '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 {
|
||||
logDebug(`Registro de sincronizacao para o ticket ${ticket.number?.value} ja existe. Nenhuma acao necessaria.`);
|
||||
logInfo(`Registro de sincronização para o ticket ${ticket.number?.value} já existe. Nenhuma ação necessária.`);
|
||||
}
|
||||
// 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;
|
||||
@ -103,57 +112,49 @@ 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 requisicoes do ServiceNow e salva-los localmente.
|
||||
* Controller para buscar tickets e requisições do ServiceNow e salvá-los localmente.
|
||||
*/
|
||||
const processSyncController = async () => {
|
||||
try {
|
||||
// 1. Pega a marca d'água da última execução bem-sucedida
|
||||
const watermark = await SyncControlModel.getWatermark('servicenow');
|
||||
logDebug(`Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
|
||||
logInfo(`📡 Buscando tickets do ServiceNow atualizados desde: ${watermark}`);
|
||||
|
||||
// Busca incidentes
|
||||
|
||||
const incidents = await fetchTicketsApi(watermark);
|
||||
const latestIncidentUpdate = await processAndSaveTickets(incidents, 'incidente');
|
||||
|
||||
logDebug('Buscando requisicoes do ServiceNow...');
|
||||
// Busca requisições
|
||||
logInfo('📡 Buscando requisições 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) {
|
||||
// Mantem margem de seguranca de 6 horas para evitar perda de eventos por atraso de replicacao, timezone ou clock skew entre sistemas.
|
||||
// Adiciona 1 segundo para evitar reprocessar o último ticket em caso de timestamps iguais
|
||||
const sixHoursInMillis = 6 * 60 * 60 * 1000;
|
||||
const nextWatermark = new Date(new Date(newWatermark).getTime() - sixHoursInMillis);
|
||||
await SyncControlModel.setWatermark('servicenow', nextWatermark);
|
||||
} else {
|
||||
logDebug("Nenhum ticket novo ou atualizado encontrado. A marca d'agua nao foi alterada.");
|
||||
logInfo("Nenhum ticket novo ou atualizado encontrado. A marca d'água não foi alterada.");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'Erro no processo de sincronizacao de tickets do ServiceNow.');
|
||||
logError(error, 'Erro no processo de sincronização 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.
|
||||
*/
|
||||
|
||||
@ -1,219 +0,0 @@
|
||||
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`.
|
||||
*/
|
||||
@ -1,22 +1,19 @@
|
||||
// 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.
|
||||
*/
|
||||
|
||||
@ -1,29 +1,23 @@
|
||||
// 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 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({
|
||||
host: '10.0.120.75',
|
||||
port: 5432,
|
||||
database: 'snglpi',
|
||||
user: 'desenvolvimento',
|
||||
password: 'Ut@2S@$M9Xs@@W'
|
||||
});
|
||||
|
||||
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', () => {
|
||||
logDebug('Conectado ao PostgreSQL');
|
||||
console.log('Conectado ao PostgreSQL');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
logError('Erro de conexão PostgreSQL:', err);
|
||||
console.error('Erro de conexão PostgreSQL:', err);
|
||||
});
|
||||
|
||||
//exporta o pool para ser usado em outros arquivos
|
||||
|
||||
@ -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 { logError, logDebug } = require('../utils/logger');
|
||||
const { logInfo, logError, logWarning } = 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) => {
|
||||
logDebug('Nova conexão GLPI estabelecida');
|
||||
logInfo('Nova conexão GLPI estabelecida');
|
||||
});
|
||||
|
||||
glpiPool.on('error', (err) => {
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
// 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 = `
|
||||
@ -23,6 +26,7 @@ class LocationMappingModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar entity_id do GLPI baseado no location_name do ServiceNow
|
||||
static async getGlpiEntityIdByName(snLocationName) {
|
||||
try {
|
||||
const query = `
|
||||
@ -44,6 +48,9 @@ class LocationMappingModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Buscar entity (id e name) do GLPI baseado no location_id do ServiceNow
|
||||
static async getGlpiEntity(snLocationId) {
|
||||
try {
|
||||
const query = `
|
||||
@ -67,8 +74,12 @@ class LocationMappingModel {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Buscar entity (id e name) do GLPI baseado no location_name do ServiceNow
|
||||
static async getGlpiEntityByName(snLocationName) {
|
||||
try {
|
||||
const query = `
|
||||
@ -94,14 +105,8 @@ 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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
module.exports = LocationMappingModel;
|
||||
@ -1,3 +1,4 @@
|
||||
// src/models/syncControlModel.js
|
||||
const pool = require('../data/database');
|
||||
const { logError, logInfo } = require('../utils/logger');
|
||||
|
||||
@ -9,6 +10,7 @@ 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}`);
|
||||
@ -33,15 +35,3 @@ 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.
|
||||
*/
|
||||
|
||||
@ -1,28 +1,11 @@
|
||||
// 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,
|
||||
@ -30,16 +13,16 @@ class TicketGlpiModel {
|
||||
'Em Espera': 4,
|
||||
'Resolvido': 5,
|
||||
'Encerrado - Omitido': 6,
|
||||
'Encerrado': 6
|
||||
'Encerrado': 6 // Mapeia para Fechado no GLPI
|
||||
};
|
||||
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
|
||||
@ -47,6 +30,7 @@ class TicketGlpiModel {
|
||||
return statusMap[glpiStatus] || 'Desconhecido';
|
||||
}
|
||||
|
||||
// Mapeamento de prioridade
|
||||
static mapPriority(snPriority) {
|
||||
const priorityMap = {
|
||||
'1': 5,
|
||||
@ -58,6 +42,7 @@ class TicketGlpiModel {
|
||||
return priorityMap[snPriority] || 3;
|
||||
}
|
||||
|
||||
// Mapeamento de categoria
|
||||
static mapCategory(shortDescription) {
|
||||
const categoryMap = {
|
||||
'Problemas com Telefonia Fixa': 5717,
|
||||
@ -68,6 +53,7 @@ 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;
|
||||
@ -87,9 +73,10 @@ class TicketGlpiModel {
|
||||
if (!isRequisicao && isTelefonia) {
|
||||
return 34;
|
||||
}
|
||||
return 0;
|
||||
return 0; // Default se nenhum critério for atendido
|
||||
}
|
||||
|
||||
// Formata o título do ticket
|
||||
static async formatTitle(ticketData) {
|
||||
try {
|
||||
let entityData = null;
|
||||
@ -105,21 +92,22 @@ class TicketGlpiModel {
|
||||
const tituloSn = ticketData.short_description || 'Sem título';
|
||||
|
||||
return {
|
||||
title: `${tipo} - ${entityName} - ${tituloSn}`, // Título formatado
|
||||
entityId: entityData?.id || GLPI_DEFAULT_ENTITY_ID
|
||||
title: `${tipo} - ${entityName} - ${tituloSn}`,
|
||||
entityId: entityData?.id || 127 // fallback para entity_id default
|
||||
};
|
||||
} 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: GLPI_DEFAULT_ENTITY_ID
|
||||
entityId: 127
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@ -135,32 +123,12 @@ class TicketGlpiModel {
|
||||
return extractInfo(/(\d{3,4})/i, description);
|
||||
};
|
||||
|
||||
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>
|
||||
`
|
||||
: '';
|
||||
// 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';
|
||||
|
||||
// Formata a descrição em HTML
|
||||
let htmlDescription = `
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<tr style="background-color:#f2f2f2;">
|
||||
@ -188,28 +156,31 @@ class TicketGlpiModel {
|
||||
<td style="padding: 8px; border: 1px solid #ddd;">${ticketData.ticket_number || 'N/A'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
${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>
|
||||
<br>
|
||||
<strong>Descrição:</strong>
|
||||
<hr>
|
||||
${ticketData.description || 'N/A'}
|
||||
`;
|
||||
return htmlDescription;
|
||||
}
|
||||
|
||||
// Cria um ticket no GLPI
|
||||
static async createTicket(ticketData) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
let entityId = GLPI_DEFAULT_ENTITY_ID;
|
||||
// Buscar entity_id com base no location_id ou location_name
|
||||
let entityId = 127; // Valor padrão (fallback)
|
||||
|
||||
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;
|
||||
@ -219,18 +190,23 @@ 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,
|
||||
entities_id: entityId, // Usar o entity_id mapeado
|
||||
name: formattedTitle,
|
||||
date: ticketData.opened_at || new Date(),
|
||||
date_mod: ticketData.updated_at || new Date(),
|
||||
status: TicketGlpiModel.mapStatus(ticketData.state || '1'), // O status é mapeado
|
||||
users_id_recipient: GLPI_DEFAULT_USER_ID,
|
||||
status: TicketGlpiModel.mapStatus(ticketData.state || '1'),
|
||||
users_id_recipient: 917,
|
||||
requesttypes_id: 1,
|
||||
content: formattedDescription,
|
||||
urgency: 3,
|
||||
@ -240,10 +216,10 @@ class TicketGlpiModel {
|
||||
itilcategories_id: TicketGlpiModel.mapCategory(ticketData.short_description),
|
||||
global_validation: 1,
|
||||
date_creation: ticketData.opened_at || new Date(),
|
||||
slas_id_ttr: slasIdTtr,
|
||||
externalid: ticketData.ticket_number
|
||||
slas_id_ttr: slasIdTtr
|
||||
};
|
||||
|
||||
// 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);
|
||||
@ -252,18 +228,20 @@ 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, '')`, // 1 = Requerente
|
||||
[ticketId, GLPI_DEFAULT_USER_ID]
|
||||
VALUES (?, ?, 1, 1, '')`,
|
||||
[ticketId, 917]
|
||||
);
|
||||
|
||||
// Adiciona o grupo
|
||||
await connection.execute(
|
||||
`INSERT INTO glpi_groups_tickets
|
||||
(tickets_id, groups_id, type)
|
||||
VALUES (?, ?, 2)`,
|
||||
[ticketId, GLPI_DEFAULT_GROUP_ID]
|
||||
[ticketId, 30]
|
||||
);
|
||||
|
||||
await connection.commit();
|
||||
@ -280,6 +258,7 @@ class TicketGlpiModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica se um ticket já existe no GLPI
|
||||
static async ticketExists(snTicketNumber) {
|
||||
try {
|
||||
const query = `
|
||||
@ -300,13 +279,12 @@ 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 != ?';
|
||||
const [rows] = await connection.execute(query,[itemId,GLPI_DEFAULT_USER_ID]);
|
||||
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]);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logError(error, 'Erro ao buscar followups do ticket');
|
||||
@ -317,17 +295,11 @@ class TicketGlpiModel {
|
||||
static async insertComment(comment, items_id) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
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 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 [result] = await connection.execute(query, values);
|
||||
return [{ id: result.insertId }];
|
||||
return [{ id: result.insertId }]; // Retorna o ID inserido
|
||||
}catch(error){
|
||||
logError(`Erro ao inserir o comentário no GLPI: ${error}`);
|
||||
} finally {
|
||||
@ -335,40 +307,11 @@ class TicketGlpiModel {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Busca tickets que foram fechados ou solucionados no GLPI
|
||||
static async getClosedTickets(watermark, limit = 100) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
// Status 5 = Solucionado, Status 6 = Fechado
|
||||
const query = `
|
||||
SELECT
|
||||
t.id,
|
||||
@ -389,14 +332,17 @@ 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, GLPI_DEFAULT_USER_ID]);
|
||||
await connection.execute(solutionQuery, [ticketId, solutionContent, 971]); // 971 = ID do usuário da integração
|
||||
|
||||
// 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]);
|
||||
|
||||
@ -410,6 +356,7 @@ class TicketGlpiModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Busca a solução de um ticket no GLPI
|
||||
static async getTicketSolution(glpiTicketId) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
@ -428,14 +375,17 @@ 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();
|
||||
}
|
||||
@ -446,10 +396,11 @@ class TicketGlpiModel {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const mappedStatus = this.mapStatus(status);
|
||||
|
||||
status = this.mapStatus(status);
|
||||
|
||||
const updateQuery = `UPDATE glpi_tickets SET status = ? WHERE id = ?`;
|
||||
await connection.execute(updateQuery, [mappedStatus, ticketId]);
|
||||
await connection.execute(updateQuery, [status, ticketId]);
|
||||
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
@ -461,6 +412,7 @@ class TicketGlpiModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Busca o status atual de um ticket no GLPI
|
||||
static async getTicketStatus(glpiTicketId) {
|
||||
const connection = await glpiPool.getConnection();
|
||||
try {
|
||||
@ -478,36 +430,19 @@ 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 é 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.
|
||||
* @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 já existe 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 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.
|
||||
*
|
||||
* 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.
|
||||
* O módulo interage com o banco de dados GLPI através da conexão glpiPool.
|
||||
*/
|
||||
module.exports = TicketGlpiModel;
|
||||
@ -1,14 +1,16 @@
|
||||
// src/models/ticketSnModel.js
|
||||
const pool = require('../data/database');
|
||||
const { logInfo, logError, logDebug } = require('../utils/logger');
|
||||
const { logInfo, logError, logWarning } = 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, justificativa, opened_at, tipo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
location_id, location_name, ramal, telefone, opened_at, tipo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT (ticket_number)
|
||||
DO UPDATE SET
|
||||
sys_id = EXCLUDED.sys_id,
|
||||
@ -21,17 +23,21 @@ 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"`;
|
||||
RETURNING id, (xmax = 0) AS "wasInserted"`; // xmax=0 é um truque do PostgreSQL para saber se foi INSERT
|
||||
|
||||
// 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;
|
||||
let callerName, callerEmail, ramal, telefone, justificativa, sysId, shortDescription, state, description, locationId, locationName, openedAt;
|
||||
|
||||
// Mapeamento inteligente de campos baseado no tipo de ticket
|
||||
let callerName, callerEmail, ramal, telefone, 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
|
||||
@ -40,7 +46,6 @@ 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;
|
||||
@ -49,12 +54,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['variables.telephone_favorecido_ti'] || ticketData.telefone || null;
|
||||
telefone = ticketData['variables.telephone_favorecido_rh'] || ticketData.telefone || null;
|
||||
sysId = ticketData.sys_id || null;
|
||||
shortDescription = ticketData.short_description || null;
|
||||
state = ticketData.state || null;
|
||||
description = ticketData.description || null;
|
||||
justificativa = ticketData.justificativa || null;
|
||||
// Para requisições, o sys_id da localização vem no final da URL do link
|
||||
const locationLink = ticketData.location?.link;
|
||||
locationId = locationLink ? locationLink.substring(locationLink.lastIndexOf('/') + 1) : null;
|
||||
locationName = ticketData.location?.display_value || null;
|
||||
@ -73,7 +78,6 @@ 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'))
|
||||
@ -85,7 +89,7 @@ class TicketSnModel {
|
||||
|
||||
try {
|
||||
const result = await pool.query(query, values);
|
||||
const wasInserted = result.rows[0].wasInserted;
|
||||
const wasInserted = result.rows[0].wasInserted; // Corrigido para corresponder ao alias "wasInserted"
|
||||
|
||||
if (wasInserted) {
|
||||
logInfo(`✅ NOVO ticket salvo! ID: ${result.rows[0].id}`, {
|
||||
@ -94,10 +98,7 @@ class TicketSnModel {
|
||||
action: 'insert'
|
||||
});
|
||||
} else {
|
||||
// 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}`, {
|
||||
logInfo(`🔄 Ticket atualizado! ID: ${result.rows[0].id}`, {
|
||||
ticket_id: result.rows[0].id,
|
||||
ticket_number: values[0],
|
||||
action: 'update'
|
||||
@ -111,11 +112,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,
|
||||
@ -127,7 +128,6 @@ 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);
|
||||
logDebug(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
|
||||
logInfo(`📋 Tickets pendentes encontrados: ${result.rows.length}`);
|
||||
return result.rows;
|
||||
|
||||
} catch (error) {
|
||||
@ -149,6 +149,7 @@ class TicketSnModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Busca um ticket pelo número
|
||||
static async findByTicketNumber(ticketNumber) {
|
||||
try {
|
||||
const query = `
|
||||
@ -165,6 +166,8 @@ class TicketSnModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Busca o status de um ticket pelo ID
|
||||
static async getStatus(ticketId) {
|
||||
try {
|
||||
const query = `
|
||||
@ -178,6 +181,9 @@ class TicketSnModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Busca um ticket pelo ID
|
||||
static async findById(ticketId) {
|
||||
try {
|
||||
const query = `
|
||||
@ -194,20 +200,24 @@ 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 = `
|
||||
@ -237,13 +247,12 @@ class TicketSnModel {
|
||||
|
||||
/**
|
||||
* @module TicketSnModel
|
||||
* @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.
|
||||
* @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.
|
||||
*
|
||||
* 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.
|
||||
* 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'.
|
||||
*/
|
||||
module.exports = TicketSnModel;
|
||||
@ -1,11 +1,15 @@
|
||||
// src/models/ticketSyncModel.js
|
||||
const { query } = require('winston');
|
||||
const pool = require('../data/database');
|
||||
const { logError, logInfo } = require('../utils/logger');
|
||||
const { logError } = 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,
|
||||
@ -16,13 +20,11 @@ class TicketSyncModel {
|
||||
sn_sync_status,
|
||||
glpi_sync_status,
|
||||
source_last
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (sn_ticket_id) DO NOTHING
|
||||
RETURNING id`;
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
|
||||
|
||||
const values = [
|
||||
data.sn_ticket_id,
|
||||
data.glpi_ticket_id,
|
||||
data.glpi_ticket,
|
||||
data.last_sn_sync,
|
||||
data.last_glpi_sync,
|
||||
data.created_at,
|
||||
@ -36,11 +38,13 @@ class TicketSyncModel {
|
||||
await pool.query(query, values);
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Erro ao criar registro de sincronizacao');
|
||||
logError(error, '❌ Erro ao criar registro de sincronização');
|
||||
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';
|
||||
@ -48,63 +52,80 @@ class TicketSyncModel {
|
||||
const { rows } = await pool.query(query, values);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
|
||||
logError(error, '❌ 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: Erro ao atualizar source_last para o ticket SN ID: ${snTicketId}`);
|
||||
logError(error, `❌ 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: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
|
||||
logError(`${error} ❌ Erro ao atualizar status de sincronização 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: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
|
||||
logError(`${error} ❌ Erro ao atualizar status de sincronização 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: Erro ao atualizar status de sincronizacao para o ID: ${ticketSyncId}`);
|
||||
logError(error, `❌ Erro ao atualizar status de sincronização 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: Erro ao buscar ID de sincronizacao por GLPI ID: ${glpiTicketId}`);
|
||||
logError(error, `❌ Erro ao buscar ID de sincronização 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 = `
|
||||
@ -114,34 +135,36 @@ class TicketSyncModel {
|
||||
const { rows } = await pool.query(query, [sysId]);
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
logError(error, `ERRO: Erro ao buscar registro de sincronizacao por sys_id: ${sysId}`);
|
||||
logError(error, `❌ Erro ao buscar registro de sincronização 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: Erro ao buscar ID de sincronizacao por SN ID: ${snTicketId}`);
|
||||
logError(error, `❌ Erro ao buscar ID de sincronização 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: Erro ao buscar ticket ${ticketNumber}}` )
|
||||
logError(`${error}, ❌ Erro ao buscar ticket ${ticketNumber}}` )
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Buscar tickets que estão sincronizados
|
||||
static async getTicketsSynced() {
|
||||
try {
|
||||
const query = `
|
||||
@ -157,11 +180,12 @@ class TicketSyncModel {
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Erro ao buscar tickets para sincronizar');
|
||||
logError(error, '❌ 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 = `
|
||||
@ -176,39 +200,16 @@ 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 != 'error_permanent' AND tsync.sn_sync_status != 'error_permanent')`;
|
||||
AND (tsync.glpi_sync_status != 'closed' AND tsync.sn_sync_status != 'closed')`;
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
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');
|
||||
logError(error, '❌ Erro ao buscar tickets para monitorar');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar tickets que precisam ter o fechamento sincronizado
|
||||
static async getTicketsPendingSolve() {
|
||||
try {
|
||||
const query = `
|
||||
@ -226,48 +227,28 @@ class TicketSyncModel {
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Erro ao buscar tickets pendentes de fechamento');
|
||||
logError(error, '❌ 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, error_retry_count
|
||||
SELECT id, sn_ticket_id, glpi_ticket_id, sn_sync_status, glpi_sync_status
|
||||
FROM ticket_sync
|
||||
WHERE (sn_sync_status LIKE '%error%' AND sn_sync_status != 'error_permanent')
|
||||
OR (glpi_sync_status LIKE '%error%' AND glpi_sync_status != 'error_permanent')
|
||||
WHERE sn_sync_status LIKE '%error%' OR glpi_sync_status LIKE '%error%'
|
||||
`;
|
||||
const { rows } = await pool.query(query);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
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}`);
|
||||
logError(error, '❌ Erro ao buscar tickets em estado de erro');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Atualiza os timestamps de última sincronização
|
||||
static async updateLastSync(snTicketId, system) {
|
||||
try {
|
||||
let fieldToUpdate;
|
||||
@ -276,21 +257,23 @@ class TicketSyncModel {
|
||||
} else if (system === 'glpi') {
|
||||
fieldToUpdate = 'last_glpi_sync';
|
||||
} else {
|
||||
return;
|
||||
return; // Não faz nada se o sistema for inválido
|
||||
}
|
||||
const query = `UPDATE ticket_sync SET ${fieldToUpdate} = NOW() WHERE sn_ticket_id = $1`;
|
||||
await pool.query(query, [snTicketId]);
|
||||
} catch (error) {
|
||||
logError(`${error} ERRO: Erro ao atualizar last_sync para o ID: ${snTicketId}`);
|
||||
logError(`${error} ❌ Erro ao atualizar last_sync para o ID: ${snTicketId}`);
|
||||
// Não lançar erro para não parar o fluxo principal
|
||||
}
|
||||
}
|
||||
|
||||
// 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: Erro ao atualizar status de sincronizacao para o GLPI ID: ${glpiTicketId}`);
|
||||
logError(`${error} ❌ Erro ao atualizar status de sincronização para o GLPI ID: ${glpiTicketId}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -299,16 +282,14 @@ class TicketSyncModel {
|
||||
}
|
||||
/**
|
||||
* @module TicketSyncModel
|
||||
* @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.
|
||||
* @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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// src/models/ticketUpdateModel.js
|
||||
const ticketSyncModel = require('./ticketSyncModel');
|
||||
const pool = require('../data/database');
|
||||
const { logError } = require('../utils/logger');
|
||||
@ -10,19 +11,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 = 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
|
||||
ticket_sync_id = $1,
|
||||
update_type = $2,
|
||||
source_system = $3,
|
||||
content = $4,
|
||||
author = $5,
|
||||
created_at = $6,
|
||||
updated_at = $7,
|
||||
destiny_id = $9
|
||||
RETURNING *;
|
||||
`;
|
||||
const values = [
|
||||
updateData.ticket_sync_id,
|
||||
updateData.update_type,
|
||||
updateData.update_type, // Corrigido para usar o tipo de atualização dinâmico
|
||||
updateData.source_system,
|
||||
updateData.content,
|
||||
updateData.author,
|
||||
@ -101,35 +102,5 @@ 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) já 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.
|
||||
*/
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
-- =============================================
|
||||
-- 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;
|
||||
@ -1,22 +0,0 @@
|
||||
-- =============================================
|
||||
-- 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);
|
||||
@ -1,25 +0,0 @@
|
||||
-- =============================================
|
||||
-- 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;
|
||||
@ -34,7 +34,6 @@ 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
|
||||
@ -99,9 +98,6 @@ 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);
|
||||
@ -226,7 +222,6 @@ 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';
|
||||
@ -249,6 +244,9 @@ 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';
|
||||
|
||||
@ -286,7 +284,3 @@ 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;
|
||||
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
pandas
|
||||
psycopg2-binary
|
||||
mysql-connector-python
|
||||
python-dotenv
|
||||
@ -1,71 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import time
|
||||
# scripts/update_location_mapping.py
|
||||
import csv
|
||||
import psycopg2
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
import pandas as pd
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import pandas as pd
|
||||
from datetime import timedelta
|
||||
|
||||
# ===========================
|
||||
# 🔧 Configuração do Ambiente
|
||||
# ===========================
|
||||
# Carrega as variáveis do .env
|
||||
load_dotenv()
|
||||
|
||||
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"
|
||||
# Configuração de logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('location_mapping.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
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'),
|
||||
@ -83,43 +41,44 @@ GLPI_DB_CONFIG = {
|
||||
'charset': os.getenv('GLPI_DB_CHARSET', 'utf8mb4')
|
||||
}
|
||||
|
||||
# Caminho para o arquivo CSV
|
||||
CSV_PATH = os.getenv('LOCATION_MAPPING_CSV_PATH')
|
||||
|
||||
# ===========================
|
||||
# 🔍 Funções auxiliares
|
||||
# ===========================
|
||||
|
||||
# Função para verificar se o CSV foi modificado recentemente
|
||||
def csv_recently_modified(minutes=2):
|
||||
"""Verifica se o CSV foi alterado recentemente."""
|
||||
if not os.path.exists(CSV_PATH):
|
||||
logger.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
|
||||
logging.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):
|
||||
logger.info(f"CSV modificado recentemente ({last_mod_time})")
|
||||
logging.info(f"CSV modificado recentemente ({last_mod_time}), rodando atualização.")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"CSV não foi modificado nos últimos {minutes} minutos.")
|
||||
logging.info(f"CSV não foi modificado nos últimos {minutes} minutos (última modificação: {last_mod_time}).")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# Função para conectar ao banco SNGLPI
|
||||
def get_snglpi_connection():
|
||||
try:
|
||||
return psycopg2.connect(**SNGLPI_DB_CONFIG)
|
||||
conn = psycopg2.connect(**SNGLPI_DB_CONFIG)
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao conectar ao banco SNGLPI: {e}", exc_info=True)
|
||||
logging.error(f"Erro ao conectar ao banco SNGLPI: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Função para conectar ao banco GLPI
|
||||
def get_glpi_connection():
|
||||
try:
|
||||
return mysql.connector.connect(**GLPI_DB_CONFIG)
|
||||
conn = mysql.connector.connect(**GLPI_DB_CONFIG)
|
||||
return conn
|
||||
except Error as e:
|
||||
logger.error(f"Erro ao conectar ao banco GLPI: {e}", exc_info=True)
|
||||
logging.error(f"Erro ao conectar ao banco GLPI: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Função para buscar location_id no SNGLPI
|
||||
def find_sn_location_id(location_name, cursor):
|
||||
try:
|
||||
query = """
|
||||
@ -132,10 +91,10 @@ def find_sn_location_id(location_name, cursor):
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao buscar location_id ({location_name}): {e}", exc_info=True)
|
||||
logging.error(f"Erro ao buscar location_id para {location_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Função para buscar entity_id no GLPI
|
||||
def find_glpi_entity_id(entity_name, cursor):
|
||||
try:
|
||||
query = """
|
||||
@ -148,41 +107,64 @@ def find_glpi_entity_id(entity_name, cursor):
|
||||
result = cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"Erro ao buscar entity_id ({entity_name}): {e}", exc_info=True)
|
||||
logging.error(f"Erro ao buscar entity_id para {entity_name}: {e}")
|
||||
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):
|
||||
logger.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
|
||||
logging.error(f"Arquivo CSV não encontrado: {CSV_PATH}")
|
||||
return False
|
||||
|
||||
with get_snglpi_connection() as snglpi_conn, get_glpi_connection() as glpi_conn:
|
||||
# Conectar aos bancos
|
||||
snglpi_conn = get_snglpi_connection()
|
||||
glpi_conn = get_glpi_connection()
|
||||
|
||||
if not snglpi_conn or not glpi_conn:
|
||||
return False
|
||||
|
||||
with snglpi_conn.cursor() as snglpi_cur, glpi_conn.cursor() as glpi_cur:
|
||||
snglpi_cur = snglpi_conn.cursor()
|
||||
glpi_cur = glpi_conn.cursor()
|
||||
|
||||
# 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
|
||||
|
||||
mappings = []
|
||||
for _, row in df.iterrows():
|
||||
sn_name = row['sn_location_name']
|
||||
glpi_name = row['glpi_entity_name']
|
||||
|
||||
sn_id = find_sn_location_id(sn_name, snglpi_cur)
|
||||
glpi_id = find_glpi_entity_id(glpi_name, glpi_cur)
|
||||
# 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:
|
||||
mappings.append((sn_id, sn_name, glpi_id, glpi_name))
|
||||
# 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:
|
||||
logger.warning(f"Entity não encontrada: {glpi_name}")
|
||||
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)
|
||||
@ -190,32 +172,31 @@ def update_mapping_table():
|
||||
"""
|
||||
snglpi_cur.executemany(insert_query, mappings)
|
||||
snglpi_conn.commit()
|
||||
logger.info(f"Tabela atualizada com {len(mappings)} registros.")
|
||||
|
||||
logging.info(f"Tabela atualizada com {len(mappings)} mapeamentos")
|
||||
else:
|
||||
logger.warning("Nenhum mapeamento válido encontrado.")
|
||||
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()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erro no processo de atualização: {e}", exc_info=True)
|
||||
logging.error(f"Erro no processo de atualização: {e}")
|
||||
# Rollback em caso de erro
|
||||
if 'snglpi_conn' in locals():
|
||||
snglpi_conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
# ===========================
|
||||
# 🔁 Loop Contínuo
|
||||
# ===========================
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("🚀 Iniciando monitoramento contínuo do Location Mapping")
|
||||
while True:
|
||||
try:
|
||||
logging.info("Iniciando atualização de mapeamento de localizações")
|
||||
if csv_recently_modified(minutes=2):
|
||||
success = update_mapping_table()
|
||||
if success:
|
||||
logger.info("✅ Atualização concluída com sucesso.")
|
||||
logging.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)
|
||||
logging.error("Falha na atualização")
|
||||
@ -1,117 +1,44 @@
|
||||
// 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, logDebug } = require('../utils/logger');
|
||||
const { logInfo, logError } = require('../utils/logger');
|
||||
const { existingCommentInServiceNow, syncCommentToServiceNow } = require('./servicenowService');
|
||||
const { sanitizeGLPIComment } = require('../utils/commentSanitizer');
|
||||
const { log } = require('winston');
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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>`;
|
||||
};
|
||||
|
||||
// Sincroniza comentários do GLPI para o Banco de Dados local
|
||||
const syncCommentsGlpitoSN = async (ticket) => {
|
||||
try {
|
||||
logDebug('Iniciando sincronizacao de comments do GLPI para o ServiceNow...');
|
||||
logInfo('🔄 Iniciando sincronização de comments do GLPI para o Service Now...');
|
||||
|
||||
const comments = await TicketGlpiModel.getFollowupsByItemId(ticket.glpi_ticket_id);
|
||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
|
||||
if (!Array.isArray(comments) || comments.length === 0) {
|
||||
logDebug(`Nenhum comentario encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||
if (comments.length === 0) {
|
||||
logInfo(`Nenhum comentário encontrado no GLPI para o ticket GLPI ID: ${ticket.glpi_ticket_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug(`${comments.length} comentarios encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
|
||||
let hasError = false;
|
||||
const syncId = await TicketSyncModel.getIdSyncByGlpiId(ticket.glpi_ticket_id);
|
||||
logInfo(`📝 ${comments.length} comentários encontrados (GLPI ID: ${ticket.glpi_ticket_id})`);
|
||||
await TicketSyncModel.updateStatus(ticket.glpi_ticket_id, 'comment_collected', 'pending_cmt_check');
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
// 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);
|
||||
if (existingComment && existingComment.destiny_id) {
|
||||
logDebug(`Comentario ${comment.id} ja sincronizado.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeGLPIComment(comment);
|
||||
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) {
|
||||
logDebug(`Comentario ${comment.id} ja sincronizado.`);
|
||||
logInfo(`🔁 Comentário ${comment.id} já sincronizado.`);
|
||||
// Não muda o source_last aqui, pois não houve nova sincronização
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Caso 2: está no SN mas não no banco
|
||||
if (!existingComment && existsInSN) {
|
||||
await TicketUpdateModel.insert({
|
||||
ticket_sync_id: syncId,
|
||||
@ -121,11 +48,13 @@ 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: existsInSN.destiny_id
|
||||
});
|
||||
// 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);
|
||||
@ -134,6 +63,7 @@ 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({
|
||||
@ -146,66 +76,42 @@ 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 comentario ${comment.id}: ${err}`);
|
||||
hasError = true;
|
||||
logError(`Erro ao sincronizar comentário ${comment.id}: ${err}`);
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 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!');
|
||||
logInfo('✅ Sincronização de comentários GLPI → SN concluída!');
|
||||
} catch (error) {
|
||||
logError(`Erro geral na sincronizacao: ${error}`);
|
||||
logError(`Erro geral na sincronização: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
|
||||
logDebug('Iniciando sincronizacao de comments do ServiceNow para o GLPI...');
|
||||
const syncCommentsSNtoGlpi = async (comments, ticket) => {
|
||||
logInfo('🔄 Iniciando sincronização de comments do ServiceNow para o GLPI...');
|
||||
|
||||
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) {
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
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');
|
||||
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.
|
||||
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, insertedId[0].id);
|
||||
await TicketUpdateModel.updateDestinyId(comment.source_id, commentId[0].id);
|
||||
|
||||
logInfo(`OK: ${isTask ? 'Tarefa' : 'Comentario'} inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
|
||||
logInfo(`✅ Comentário inserido com sucesso no GLPI para o chamado: ${ticket.glpi_ticket_id}`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`ERRO: Erro ao processar atualizacao do ticket ${ticket.sn_ticket_id}: ${error}`);
|
||||
logError(`⚠️ Erro ao processar comentário do ticket ${ticket.sn_ticket_id}: ${error}`);
|
||||
|
||||
try {
|
||||
await TicketSyncModel.updateStatus(ticket.sn_ticket_id, 'synced', 'error');
|
||||
@ -216,20 +122,18 @@ const syncCommentsSNtoGlpi = async (comments, ticket, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @module glpiCommentService
|
||||
* @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.
|
||||
* @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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
|
||||
@ -1,26 +1,27 @@
|
||||
// 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, logDebug } = require('../utils/logger');
|
||||
const { addWorkNoteToServiceNow, updateExternalTicketInServiceNow, queueExternalTicketLinkRetry, reassignTicketInServiceNow } = require('./servicenowService');
|
||||
const { stripHTML } = require('../utils/commentSanitizer');
|
||||
const { logInfo, logError, logWarning } = require('../utils/logger');
|
||||
const { addWorkNoteToServiceNow } = require('./servicenowService');
|
||||
|
||||
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 {
|
||||
logDebug('🔄 Iniciando sincronização para GLPI...');
|
||||
logInfo('🔄 Iniciando sincronização para GLPI...');
|
||||
// 1. Buscar tickets pendentes
|
||||
const pendingTickets = await TicketSnModel.getPendingTickets();
|
||||
|
||||
if (pendingTickets.length === 0) {
|
||||
logDebug('✅ Nenhum ticket pendente para sincronizar');
|
||||
logInfo('✅ 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);
|
||||
}
|
||||
@ -33,43 +34,46 @@ 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');
|
||||
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp
|
||||
|
||||
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) {
|
||||
if (glpiLiveStatus === 6) { // Status 6 = Fechado no GLPI
|
||||
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;
|
||||
return; // Não processa mais este ticket
|
||||
}
|
||||
// --- 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;
|
||||
return; // Adiciona este return para evitar a criação de um novo ticket
|
||||
}
|
||||
|
||||
// 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,
|
||||
@ -82,16 +86,35 @@ 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');
|
||||
await TicketSyncModel.updateLastSync(ticket.id, 'glpi'); // Atualiza o timestamp
|
||||
|
||||
// 4. Atualizar registro de sincronização
|
||||
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) {
|
||||
@ -100,68 +123,68 @@ const processSingleTicket = async (ticket) => {
|
||||
}
|
||||
};
|
||||
|
||||
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}`;
|
||||
const checkGlpiStatusChanges = async () => {
|
||||
try {
|
||||
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 };
|
||||
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 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.';
|
||||
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 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>`;
|
||||
// 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'
|
||||
|
||||
await TicketGlpiModel.insertComment({ content: styledMessage }, ticket.glpi_ticket_id);
|
||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi');
|
||||
// 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,
|
||||
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'
|
||||
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}`,
|
||||
});
|
||||
logInfo(`Nota de encerramento do ServiceNow adicionada ao GLPI ${ticket.glpi_ticket_id}.`);
|
||||
return { ok: true, isNew: true };
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, `Falha ao adicionar nota de encerramento do SN no GLPI ${ticket.glpi_ticket_id}`);
|
||||
return { ok: false, isNew: false };
|
||||
logError(error, '🚨Falha ao verificar mudanças de status no GLPI');
|
||||
}
|
||||
};
|
||||
|
||||
@ -169,35 +192,23 @@ 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');
|
||||
await TicketSyncModel.updateLastSync(ticket.sn_ticket_id, 'glpi'); // Atualiza o timestamp
|
||||
|
||||
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;
|
||||
throw error; // Propaga o erro para o controller tratar
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
syncTicketsToGlpi,
|
||||
closeTicketInGlpi,
|
||||
addServiceNowClosureNoteToGlpi,
|
||||
isOutOfScopeSolution,
|
||||
handleOutOfScopeResolution
|
||||
checkGlpiStatusChanges,
|
||||
closeTicketInGlpi
|
||||
};
|
||||
|
||||
/**
|
||||
* @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 já 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 já 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".
|
||||
*/
|
||||
|
||||
0
src/services/hubsoftService.js
Normal file
0
src/services/hubsoftService.js
Normal file
@ -1,165 +1,90 @@
|
||||
// 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, logDebug } = require('../utils/logger');
|
||||
const { logInfo, logError, logSync } = require('../utils/logger');
|
||||
const TicketUpdateModel = require('../models/ticketUpdateModel');
|
||||
const TicketSyncModel = require('../models/ticketSyncModel');
|
||||
|
||||
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 { log } = require('winston');
|
||||
|
||||
const fetchTicketsFromServiceNow = async (watermark) => {
|
||||
try {
|
||||
// A watermark vem como string 'YYYY-MM-DD HH:MM:SS'. Converte para Date.
|
||||
const watermarkDate = new Date(watermark);
|
||||
const watermarkBRT = new Date(watermarkDate.getTime());
|
||||
// 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 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 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 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 ticketsResponse = await axios.get(url, {
|
||||
auth: apiConfig.servicenowAuthentication.auth
|
||||
});
|
||||
return ticketsResponse.data.result || [];
|
||||
} catch (error) {
|
||||
logError(error, 'SERVICE NOW Tickets');
|
||||
throw error;
|
||||
throw error; // Lança o erro para que o controller não atualize a watermark
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fetchRequestsFromServiceNow = async (watermark) => {
|
||||
try {
|
||||
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';
|
||||
// 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 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;
|
||||
throw error; // Lança o erro
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Função para obter o sys_id de um ticket pela tabela de sincronização do banco de dados local
|
||||
const getTicketSysId = async (ticketId) => {
|
||||
try {
|
||||
const ticket = await TicketSnModel.findById(ticketId);
|
||||
if (!ticket) {
|
||||
logError(`Ticket com ID local ${ticketId} nao encontrado.`, 'getTicketSysId');
|
||||
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketSysId');
|
||||
return null;
|
||||
}
|
||||
return ticket.sys_id;
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao obter sys_id do ticket');
|
||||
logError(error, '🚨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} nao encontrado.`, 'getTicketTypeById');
|
||||
logError(`Ticket com ID local ${ticketId} não encontrado.`, 'getTicketTypeById');
|
||||
return null;
|
||||
}
|
||||
const ticketType = ticket.tipo;
|
||||
logInfo(`INFO: Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
|
||||
logInfo(`⚠️ Tipo do ticket ${ticketId} - Tipo: ${ticketType}`, { ticketId, ticketType });
|
||||
return ticketType;
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao obter tipo do ticket');
|
||||
logError(error, '🚨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 {
|
||||
|
||||
@ -172,19 +97,19 @@ const getCommentId = async (sysId, comment) => {
|
||||
const data = response.data;
|
||||
const comments = data.result;
|
||||
|
||||
// Procurar o comentario exato na lista retornada
|
||||
// Procurar o comentário 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 comentario encontrado
|
||||
return item.sys_id; // Retorna o sys_id do comentário encontrado
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao obter ID do comentario no ServiceNow');
|
||||
logError(error, '🚨Falha ao obter ID do comentário 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 = '';
|
||||
@ -211,41 +136,42 @@ const createCommentInServiceNow = async (snId, sysId, comment, ticketType) => {
|
||||
|
||||
// Verificar se retorna status 200 para sucesso
|
||||
if (response.status !== 200) {
|
||||
logError(`ERRO: Falha ao criar comentario. Status: ${response.status}`, { sysId, ticketType });
|
||||
logError(`❌ Falha ao criar comentário. Status: ${response.status}`, { sysId, ticketType });
|
||||
return null;
|
||||
}
|
||||
|
||||
// retornar id do comentario criado
|
||||
// retornar id do comentário criado
|
||||
const commentId = await getCommentId(sysId, comment);
|
||||
|
||||
if (!commentId) {
|
||||
logError('ERRO: Comentario criado, mas nao foi possivel obter o ID do comentario', { sysId });
|
||||
logError('❌ Comentário criado, mas não foi possível obter o ID do comentário', { sysId });
|
||||
return null;
|
||||
}
|
||||
|
||||
logInfo(`OK: Comentario criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
|
||||
logInfo(`✅ Comentário criado com sucesso no ServiceNow. Comment ID: ${commentId}`, { sysId, ticketType });
|
||||
|
||||
return commentId;
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao criar comentario no ServiceNow');
|
||||
throw error;
|
||||
logError(error, '🚨Falha ao criar comentário no ServiceNow');
|
||||
}
|
||||
}
|
||||
|
||||
// Função para criar um comentário
|
||||
const syncCommentToServiceNow = async (ticketId, comment) => {
|
||||
logInfo(`Iniciando sincronizacao do comentario para o ticket SN Ticket: ${ticketId}`);
|
||||
logInfo(`Iniciando sincronização do comentário para o ticket SN Ticket: ${ticketId}`);
|
||||
const snSysId = await getTicketSysId(ticketId);
|
||||
const ticketType = await getTicketTypeById(ticketId);
|
||||
|
||||
if (!snSysId) {
|
||||
logError(`ERRO: sys_id do ServiceNow nao encontrado para o ticket SN Ticket: ${ticketId}`);
|
||||
logError(`❌ sys_id do ServiceNow não 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);
|
||||
@ -253,15 +179,18 @@ const existingCommentInServiceNow = async (ticketId, comment) => {
|
||||
return commentExists;
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao verificar existencia do comentario no ServiceNow');
|
||||
logError(error, '🚨Falha ao verificar existência do comentário 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 {
|
||||
|
||||
logDebug(`Iniciando busca de comentarios/worknotes do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
||||
logInfo(`Iniciando busca de comentários do ServiceNow para o ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
||||
|
||||
const sys_id = ticket.sys_id;
|
||||
|
||||
@ -275,13 +204,12 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
while (true) {
|
||||
page++;
|
||||
const params = {
|
||||
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^elementINcomments,work_notes`,
|
||||
sysparm_query: `element_id=${encodeURIComponent(sys_id)}^element=comments`,
|
||||
sysparm_fields: 'sys_id,element,value,sys_created_on,sys_created_by,element_id',
|
||||
sysparm_display_value: true,
|
||||
sysparm_limit: pageSize,
|
||||
sysparm_offset: offset
|
||||
};
|
||||
logDebug(`Buscando pagina ${page} de comentarios do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
||||
logInfo(`Buscando página ${page} de comentários do ticket SN Ticket: ${ticket.glpi_ticket_id}`);
|
||||
const response = await axios.get(apiConfig.snTableJournalConfig.baseUrl, {
|
||||
auth: apiConfig.servicenowAuthentication.auth,
|
||||
params
|
||||
@ -292,77 +220,53 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
|
||||
|
||||
if (!Array.isArray(comments) || comments.length === 0) {
|
||||
break; // nao ha mais comentarios
|
||||
break; // não há mais comentários
|
||||
}
|
||||
|
||||
allComments.push(...comments);
|
||||
|
||||
if (comments.length < pageSize) {
|
||||
break; // ultima pagina
|
||||
break; // última página
|
||||
}
|
||||
|
||||
// avancar para proxima pagina
|
||||
// avançar para próxima página
|
||||
offset += pageSize;
|
||||
|
||||
// protecao extra para evitar loop infinito em caso de comportamento inesperado da API
|
||||
// proteção extra para evitar loop infinito em caso de comportamento inesperado da API
|
||||
if (page >= internalMaxPages) {
|
||||
logError(new Error('Max pages reached while paginating comments'), { sn_ticket_id: ticket.sn_ticket_id, page });
|
||||
logError(new Error('Max pages reached while paginating comments'), { ticketId, page });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
// 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}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
logDebug(`Encontrado ${orderedComments.length} atualizacoes (comments/work_notes) para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
|
||||
logInfo(`Encontrado ${filteredComments.length} comentários para o ticket SN Ticket: ${ticket.glpi_ticket_id}`)
|
||||
|
||||
|
||||
const commentsInserted = [];
|
||||
// Insere cada comment no banco intermediario
|
||||
for (const comment of orderedComments) {
|
||||
for (const comment of filteredComments) {
|
||||
const existingComment = await TicketUpdateModel.getBySourceId(comment.sys_id);
|
||||
|
||||
if (existingComment) {
|
||||
logDebug(`Comentario: ${comment.sys_id} ja existe no banco de dados`, { step: 3 });
|
||||
logInfo(`Comentário: ${comment.sys_id} já existe no banco de dados`, { step: 3 });
|
||||
continue;
|
||||
} else {
|
||||
|
||||
const commentData = {
|
||||
ticket_sync_id: ticket.id,
|
||||
update_type: comment.element === 'work_notes' ? 'task' : 'comment',
|
||||
update_type: 'comment',
|
||||
source_system: 'servicenow',
|
||||
content: comment.value,
|
||||
created_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||
author: comment.sys_created_by || 'ServiceNow',
|
||||
updated_at: normalizeServiceNowDateTime(comment.sys_created_on),
|
||||
created_at: new Date(comment.sys_created_on),
|
||||
author: "CAOA",
|
||||
updated_at: new Date(),
|
||||
source_id: comment.sys_id,
|
||||
destiny_id: null
|
||||
};
|
||||
@ -372,9 +276,9 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
|
||||
if (commentInserted.success) {
|
||||
commentsInserted.push(commentData);
|
||||
logInfo(`OK: Comentario ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
|
||||
logInfo(`✅ Comentário ID ${comment.sys_id} do ${commentData.source_system} inserido no banco!`, { step: 4 });
|
||||
} else {
|
||||
logError(`ERRO: Erro ao inserir comentario ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
|
||||
logError(`❌ Erro ao inserir comentário ID ${comment.sys_id} no banco: ${commentInserted.error}`, { step: 4 });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -383,16 +287,17 @@ const fetchCommentsFromServiceNow = async (ticket) => {
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao buscar comentarios do ServiceNow');
|
||||
logError(error, '🚨Falha ao buscar comentários do ServiceNow');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Adiciona no final do arquivo src/services/servicenowService.js
|
||||
const { stripHTML } = require('../utils/commentSanitizer');
|
||||
const fetchAndProcessClosedTicketsFromSN = async () => {
|
||||
try {
|
||||
logInfo('INFO: Verificando status de tickets sincronizados no ServiceNow...');
|
||||
logInfo('📡 Verificando status de tickets sincronizados no ServiceNow...');
|
||||
|
||||
// 1. Busca apenas os tickets que estao abertos e sincronizados no nosso banco.
|
||||
// 1. Busca apenas os tickets que estão abertos e sincronizados no nosso banco.
|
||||
const syncedTickets = await TicketSyncModel.getTicketsSynced();
|
||||
|
||||
if (syncedTickets.length === 0) {
|
||||
@ -410,12 +315,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 sincronizacao de fechamento...`);
|
||||
logInfo(`Ticket SN ${ticketSnDetails.ticket_number} foi Fechado permanentemente. Marcando para sincronização de fechamento...`);
|
||||
|
||||
// Sanitiza o conteudo da nota de fechamento antes de salvar.
|
||||
// Sanitiza o conteúdo 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 intermediario.
|
||||
// Salva a nota de fechamento no nosso banco intermediário.
|
||||
await TicketUpdateModel.insert({
|
||||
ticket_sync_id: ticket.id,
|
||||
source_system: 'servicenow',
|
||||
@ -432,35 +337,36 @@ const fetchAndProcessClosedTicketsFromSN = async () => {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logError(error, 'ERRO: Falha ao buscar chamados encerrados do ServiceNow');
|
||||
logError(error, '🚨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) => {
|
||||
let sysId = null; // Declarar sysId fora do try para estar disponivel no catch
|
||||
// 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
|
||||
try {
|
||||
const ticketSn = await TicketSnModel.findById(snTicketId);
|
||||
if (!ticketSn) {
|
||||
logError(`Ticket SN com ID ${snTicketId} nao encontrado para atualizacao de status.`, 'updateStatusInServiceNow');
|
||||
logError(`Ticket SN com ID ${snTicketId} não encontrado para atualização 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 = {};
|
||||
|
||||
@ -469,7 +375,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 }; // Requisicoes podem ter um fluxo diferente
|
||||
payload = { state: stateCode }; // Requisições podem ter um fluxo diferente
|
||||
} else {
|
||||
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
|
||||
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
|
||||
@ -481,34 +387,30 @@ const updateStatusInServiceNow = async (snTicketId, state) => {
|
||||
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Atualiza o timestamp
|
||||
|
||||
if (response.status !== 200) {
|
||||
logError(`ERRO: Falha ao atualizar status. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType, state: stateCode });
|
||||
logError(`❌ 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}`);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
logInfo(`✅ Status do ticket atualizado com sucesso no ServiceNow.`, { sysId, ticketType, state: stateCode });
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError(error, `ERRO: Falha ao atualizar status no ServiceNow para o ticket com sys_id: ${sysId}`);
|
||||
logError(error, `🚨 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} nao encontrado para fechamento.`, 'closeTicketInServiceNow');
|
||||
logError(`Ticket SN com ID ${snTicketId} não encontrado para fechamento.`, 'closeTicketInServiceNow');
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -517,6 +419,9 @@ 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}`;
|
||||
@ -527,20 +432,21 @@ 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 Tecnico Sothis: ${closeNotes}`
|
||||
work_notes: `Ticket Encerrado por Suporte Técnico Sothis: ${closeNotes}`
|
||||
};
|
||||
|
||||
|
||||
} else if (ticketType === 'requisicao') {
|
||||
|
||||
url = `${apiConfig.snTableRequestConfig.baseUrl}/${sysId}`;
|
||||
payload = {
|
||||
state: '6',
|
||||
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}`,
|
||||
|
||||
work_notes: closeNotes || 'Requisição resolvida automaticamente via integração GLPI.'
|
||||
};
|
||||
|
||||
console.log(url);
|
||||
console.log(payload);
|
||||
|
||||
} else {
|
||||
logError(`Tipo de ticket desconhecido: ${ticketType}`, { sysId, ticketType });
|
||||
throw new Error(`Tipo de ticket desconhecido: ${ticketType}`);
|
||||
@ -555,34 +461,28 @@ const closeTicketInServiceNow = async (snTicketId, closeNotes, resolvedAt) => {
|
||||
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow'); // Corrigido para usar snTicketId
|
||||
|
||||
if (response.status !== 200) {
|
||||
logError(`ERRO: Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
|
||||
logError(`❌ Falha ao resolver ticket. Status: ${response.status} - ${response.statusText}`, { sysId, ticketType });
|
||||
throw new Error(`Falha ao fechar ticket no ServiceNow: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
logInfo(`✅ Ticket resolvido com sucesso no ServiceNow.`, { sysId, ticketType });
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError(error, `ERRO: Falha ao fechar ticket no ServiceNow (sysId: ${sysId})`);
|
||||
logError(error, `🚨 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} nao encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
|
||||
logError(`Ticket SN com ID ${snTicketId} não encontrado para adicionar work note.`, 'addWorkNoteToServiceNow');
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -606,245 +506,35 @@ const addWorkNoteToServiceNow = async (snTicketId, workNote) => {
|
||||
});
|
||||
|
||||
await TicketSyncModel.updateLastSync(snTicketId, 'servicenow');
|
||||
logInfo(`OK: Work note adicionada com sucesso ao ticket SN ${sysId}.`);
|
||||
logInfo(`✅ Work note adicionada com sucesso ao ticket SN ${sysId}.`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError(error, `ERRO: Falha ao adicionar work note ao ticket SN (sysId: ${sysId})`);
|
||||
logError(error, `🚨 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 variavel de catalogo (sc_item_option) para um Request Item (RITM) especifico.
|
||||
* Busca o valor de uma variável de catálogo (sc_item_option) para um Request Item (RITM) específico.
|
||||
* @param {string} ritmSysId - O sys_id do sc_req_item (RITM).
|
||||
* @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.
|
||||
* @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.
|
||||
*/
|
||||
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.item_option_new.name,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.value`;
|
||||
|
||||
const response = await axios.get(url, {
|
||||
auth: apiConfig.servicenowAuthentication.auth
|
||||
});
|
||||
|
||||
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'])
|
||||
}));
|
||||
const result = response.data.result;
|
||||
return (result && result.length > 0) ? result : null;
|
||||
} catch (error) {
|
||||
logError(error, `ERRO: Falha ao buscar valor da variavel '${variableName}' para RITM ${ritmSysId}`);
|
||||
logError(error, `🚨 Falha ao buscar valor da variável '${variableName}' para RITM ${ritmSysId}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -852,40 +542,15 @@ const fetchScItemOptionValue = async (ritmSysId, variableName) => {
|
||||
module.exports = {
|
||||
fetchTicketsFromServiceNow,
|
||||
fetchRequestsFromServiceNow,
|
||||
fetchTicketLiveStatusFromServiceNow,
|
||||
fetchTicketCloserFromServiceNow,
|
||||
getTicketSysId,
|
||||
getTicketTypeById,
|
||||
createCommentInServiceNow,
|
||||
getCommentId,
|
||||
existingCommentInServiceNow,
|
||||
syncCommentToServiceNow,
|
||||
fetchCommentsFromServiceNow,
|
||||
fetchAndProcessClosedTicketsFromSN,
|
||||
closeTicketInServiceNow,
|
||||
updateStatusInServiceNow,
|
||||
addWorkNoteToServiceNow,
|
||||
addCommentToServiceNow,
|
||||
fetchScItemOptionValue,
|
||||
updateExternalTicketInServiceNow,
|
||||
reassignTicketInServiceNow,
|
||||
queueExternalTicketLinkRetry,
|
||||
processPendingExternalTicketLinks
|
||||
closeTicketInServiceNow, // Adicionei a vírgula aqui
|
||||
updateStatusInServiceNow, // Adicionei a vírgula aqui
|
||||
addWorkNoteToServiceNow, // Adicionei a vírgula aqui
|
||||
fetchScItemOptionValue // Nova função
|
||||
};
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
const { logDebug, logWarning } = require('./logger');
|
||||
// src/utils/commentSanitizer.js
|
||||
const { logInfo, 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(/</g, '<') // < para <
|
||||
.replace(/>/g, '>') // > para >
|
||||
@ -13,6 +18,7 @@ const stripHTML = (html) => {
|
||||
.replace(/&/g, '&') // & para &
|
||||
.replace(/"/g, '"'); // " para "
|
||||
|
||||
// DEPOIS: remove tags HTML
|
||||
cleaned = cleaned
|
||||
.replace(/<br\s*\/?>/gi, '\n') // <br> para quebra de linha
|
||||
.replace(/<p>/gi, '') // Remove <p>
|
||||
@ -33,24 +39,33 @@ 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;
|
||||
}
|
||||
@ -61,22 +76,29 @@ 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) {
|
||||
logDebug('🔧 Comentário sanitizado', {
|
||||
logInfo('🔧 Comentário sanitizado', {
|
||||
original: commentObj.content.substring(0, 100) + '...',
|
||||
cleaned: content.substring(0, 100) + '...'
|
||||
});
|
||||
@ -86,16 +108,17 @@ const sanitizeGLPIComment = (commentObj) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* 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:
|
||||
* - `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]", já 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.
|
||||
* 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.exports = {
|
||||
@ -104,3 +127,6 @@ module.exports = {
|
||||
handleImages,
|
||||
cleanJSONMetadata
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
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
|
||||
@ -9,69 +8,40 @@ 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: logLevel,
|
||||
format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.errors({ stack: true })),
|
||||
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()
|
||||
),
|
||||
transports: [
|
||||
// Log geral da aplicação
|
||||
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;
|
||||
})
|
||||
),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'app.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
// Log de erros
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(logsDir, 'error-%DATE%.log'),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error',
|
||||
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;
|
||||
})
|
||||
),
|
||||
})
|
||||
maxsize: 5242880,
|
||||
maxFiles: 3
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Log no console (controlado por variavel de ambiente)
|
||||
if (logToConsole) {
|
||||
// Log no console em desenvolvimento
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
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;
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
return `${timestamp} [${level}]: ${stack || message}`;
|
||||
})
|
||||
)
|
||||
}));
|
||||
@ -94,15 +64,9 @@ const logWarning = (message, meta = {}) => {
|
||||
logger.warn(message, meta);
|
||||
};
|
||||
|
||||
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.
|
||||
// Log de sincronização específico
|
||||
const logSync = (service, count, type) => {
|
||||
logger.debug(`SYNC: ${service} - ${count} ${type} sincronizados`, {
|
||||
logger.info(`SYNC: ${service} - ${count} ${type} sincronizados`, {
|
||||
service,
|
||||
count,
|
||||
type
|
||||
@ -114,6 +78,5 @@ module.exports = {
|
||||
logError,
|
||||
logInfo,
|
||||
logWarning,
|
||||
logDebug,
|
||||
logSync,
|
||||
logSync
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user