V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo

- src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/
  SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente
  (secao 15 do ciclo final: matriz completa de guarda de seguranca).
  computeMessageHash/buildSendKey/isDuplicate implementam a chave
  logica de idempotencia da secao 13 (report_type+periodo+destino+hash).
- src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION.
- src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/
  motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType
  (.mode) e sendLogRows (Data Table de log de envios).
- tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios',
  troca do HTTP Slack com webhook placeholder por node Slack nativo
  (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao
  Coordenador GLPI), branches de sucesso/falha gravando o log de envio
  so apos confirmacao real do Slack.
- test/sendGuard.test.js, test/workflowStructure.test.js (novos):
  cobrem a matriz de guarda de seguranca e travam regressao do bug de
  timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug
  de sendGuard.js nao concatenado no Code node gerado.
- Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava
  na lista de modulos concatenados pelo gerador, deixando canSend/
  computeMessageHash/buildSendKey undefined em runtime.
- Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6):
  n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado
  nos 9 nodes de leitura em lote.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Automacao GLPI
2026-08-25 09:22:42 -03:00
co-authored by Claude Sonnet 5
parent bdfa675d89
commit 64ddfb9b7b
10 changed files with 815 additions and 84 deletions
+14 -1
View File
@@ -50,6 +50,7 @@ const MOD = {
format: readModuleBody('format.js'),
sqlGuard: readModuleBody('sqlGuard.js'),
queries: readModuleBody('queries.js'),
sendGuard: readModuleBody('sendGuard.js'),
pipeline: readModuleBody('pipeline.js'),
};
@@ -165,7 +166,7 @@ return [{ json: { aiCall: { attempted: false, error: null, content: null } } }];
// Node: Code - Motor V2: Relatorio Completo
// ---------------------------------------------------------------------
const CODE_MOTOR_V2 = `${HEADER_AVISO}
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.pipeline)}
${joinBlocks(MOD.period, MOD.baseline, MOD.anomaly, MOD.insights, MOD.aiSchema, MOD.format, MOD.sendGuard, MOD.pipeline)}
const base = $('Code - Periodo e Queries SQL').first().json;
const config = base.config;
@@ -194,6 +195,16 @@ const historicoRows = $('Data Table - Buscar Historico Anterior')
const historicoAnterior = historicoRows.length ? historicoRows[0] : null;
const todayIso = new Date().toISOString().slice(0, 10);
// $execution.mode: 'manual' quando disparado pelo botao "Execute workflow";
// qualquer outro valor (ex: 'trigger') quando disparado pelo Schedule
// Trigger. Ver src/v2/sendGuard.js#canSend — manual so envia com
// FORCE_SEND=true explicito.
const triggerType = $execution.mode === 'manual' ? 'manual' : 'scheduled';
const sendLogRows = $('Data Table - Buscar Log de Envios')
.all()
.map((i) => i.json)
.filter((r) => r && r.send_key);
const input = {
period,
indicators,
@@ -207,6 +218,8 @@ const input = {
previousPendentes: historicoAnterior ? Number(historicoAnterior.pendentes) : null,
altaPrioridadeAnterior: historicoAnterior ? Number(historicoAnterior.alta_prioridade_pendentes) : null,
todayIso,
triggerType,
sendLogRows,
};
const relatorio = buildReport(input, config);
+183 -39
View File
@@ -9,6 +9,15 @@ const MYSQL_CRED = { mySql: { id: 'dsVj1nmOIXRvix9n', name: 'GLPI' } };
const OPENAI_CRED = { openAiApi: { id: 'WUGriAn5FgCiLza2', name: 'OpenAI - Coordenador GLPI' } };
const DATATABLE_MEMORIA = { mode: 'list', value: 'wCxOOGhtrzjkPglB', cachedResultName: 'dev_glpi_v2_insights_memory' };
const DATATABLE_HISTORICO = { mode: 'list', value: 'fa7GJSlCaguJ9OBl', cachedResultName: 'dev_glpi_v2_run_history' };
// Data Table de log de envios (idempotencia, secao 13) — criada em
// 2026-08-25 via n8n_manage_datatable (createTable).
const DATATABLE_SEND_LOG = { mode: 'list', value: 'SWIeAdMmpMCZ2rVE', cachedResultName: 'dev_glpi_v2_send_log' };
// Credencial Slack REAL, ja provisionada e em uso pelo projeto irmao "[DEV]
// Coordenador GLPI - Chamados Abertos v2 (Shadow)" (workflow y6JFEnp7Z2IJEUe2,
// node "Slack - Enviar Mensagem") — reaproveitada aqui em vez de criar uma
// credencial nova, conforme exigido pela missao ("nao me peca webhook se ja
// existe nas credenciais do ambiente").
const SLACK_CRED = { slackApi: { id: 'lNqwBBrtLfkRqw2X', name: 'Slack - Bot Coordenador (Chamados Suporte)' } };
function setAssignment(id, name, type, value) {
return { id, name, type, value };
@@ -54,7 +63,6 @@ module.exports = function build(codeBodies) {
assignments: {
assignments: [
setAssignment('data_alvo', 'data_alvo', 'string', ''),
setAssignment('slack_webhook_url', 'slack_webhook_url', 'string', 'https://hooks.slack.com/services/SEU/WEBHOOK/AQUI'),
setAssignment('AI_ENABLED', 'AI_ENABLED', 'boolean', true),
setAssignment('AI_MODEL', 'AI_MODEL', 'string', 'gpt-5.5'),
setAssignment('AI_MAX_TICKETS', 'AI_MAX_TICKETS', 'number', 40),
@@ -68,6 +76,16 @@ module.exports = function build(codeBodies) {
setAssignment('MAX_REPORT_INSIGHTS', 'MAX_REPORT_INSIGHTS', 'number', 5),
setAssignment('DEV_MODE', 'DEV_MODE', 'boolean', true),
setAssignment('SHADOW_MODE', 'SHADOW_MODE', 'boolean', true),
// Guarda de envio real (secoes 12/13/15) — ver src/v2/sendGuard.js.
// SEND_ENABLED/FORCE_SEND nao tem efeito sozinhos: DEV_MODE=true e
// SHADOW_MODE=true continuam bloqueando qualquer envio, absoluto,
// ate a promocao explicita deste workflow para producao.
setAssignment('SEND_ENABLED', 'SEND_ENABLED', 'boolean', true),
setAssignment('FORCE_SEND', 'FORCE_SEND', 'boolean', false),
// Canal Slack de destino do Resumo Diario/Semanal. Vazio ate
// confirmacao explicita do usuario sobre qual canal deve receber
// (nao deve ser adivinhado — ver secao 6 da missao).
setAssignment('SLACK_DESTINATION', 'SLACK_DESTINATION', 'string', ''),
setAssignment('EXTENDED_METRICS_ENABLED', 'EXTENDED_METRICS_ENABLED', 'boolean', false),
setAssignment('ANOMALY_MULTIPLIER', 'ANOMALY_MULTIPLIER', 'number', 2.0),
setAssignment('ANOMALY_MIN_DELTA', 'ANOMALY_MIN_DELTA', 'number', 3),
@@ -145,6 +163,42 @@ module.exports = function build(codeBodies) {
orderByDirection: 'DESC',
},
},
{
id: 'dt-buscar-log-envios',
name: 'Data Table - Buscar Log de Envios',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [2860, 260],
// executeOnce + alwaysOutputData: mesmo motivo dos outros Data Table
// reads (ver Data Table - Buscar Memoria de Insights acima).
executeOnce: true,
alwaysOutputData: true,
parameters: {
resource: 'row',
operation: 'get',
dataTableId: DATATABLE_SEND_LOG,
filters: {
conditions: [
{
keyName: 'report_type',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.history_bucket }}",
},
{
keyName: 'period_start',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_inicio }}",
},
{
keyName: 'period_end',
condition: 'eq',
keyValue: "={{ $('Code - Periodo e Queries SQL').first().json.period.data_fim }}",
},
],
},
returnAll: true,
},
},
{
id: 'code-contexto-ia',
name: 'Code - Preparar Contexto IA',
@@ -283,23 +337,19 @@ module.exports = function build(codeBodies) {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [
// ATENCAO: operation deve ser 'false' aqui (checa "e falso"), NAO
// 'true'. Bug real encontrado e corrigido em 2026-08-25: com
// operation:'true' a condicao checava "SHADOW_MODE e true" (o
// OPOSTO do que o nome do node promete), e quase mandou uma
// mensagem real ao Slack durante validacao em DEV — so nao
// enviou porque a URL do webhook ainda era o placeholder.
// A decisao completa (SHADOW_MODE + DEV_MODE + SEND_ENABLED +
// FORCE_SEND por tipo de gatilho + bloqueio de duplicata por
// idempotencia) ja foi calculada em JS testado por unidade
// (src/v2/sendGuard.js#canSend, chamado dentro de
// src/v2/pipeline.js#buildReport) — este IF so LE o resultado.
// Isso evita reproduzir a logica de guarda como condicoes soltas
// no n8n (fonte do bug real de operation:'true'/'false' invertido
// encontrado e corrigido em 2026-08-25).
{
id: 'cond-shadow',
leftValue: '={{ $json.config.SHADOW_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
},
{
id: 'cond-dev',
leftValue: '={{ $json.config.DEV_MODE }}',
rightValue: false,
operator: { type: 'boolean', operation: 'false', singleValue: true },
id: 'cond-pode-enviar',
leftValue: '={{ $json.envio.pode_enviar }}',
rightValue: true,
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
combinator: 'and',
@@ -308,37 +358,122 @@ module.exports = function build(codeBodies) {
},
},
{
id: 'http-slack-v2',
name: 'HTTP - Slack Enviar Mensagem V2',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [4420, 260],
id: 'slack-enviar-mensagem-v2',
name: 'Slack - Enviar Mensagem V2',
type: 'n8n-nodes-base.slack',
typeVersion: 2.5,
position: [4420, 200],
retryOnFail: true,
maxTries: 2,
waitBetweenTries: 3000,
// continueErrorOutput: mesmo padrao do node irmao "Slack - Enviar
// Mensagem" (workflow y6JFEnp7Z2IJEUe2) — separa sucesso/falha em duas
// saidas, para so gravar status=success no log de idempotencia DEPOIS
// que o Slack de fato confirmar o envio (secao 13 da missao).
onError: 'continueErrorOutput',
parameters: {
method: 'POST',
url: "={{ $('Config - V2').first().json.slack_webhook_url }}",
sendBody: true,
contentType: 'json',
specifyBody: 'keypair',
bodyParameters: { parameters: [{ name: 'text', value: '={{ $json.mensagem_v2 }}' }] },
select: 'channel',
// channelId.value fica vazio ate o usuario confirmar o canal de
// destino do Resumo Diario/Semanal (nao deve ser adivinhado — ver
// secao 6 da missao). Ate la, IF - Pode Enviar so libera este node
// com SHADOW_MODE=false e SEND_ENABLED=true explicitos, e o teste
// controlado de envio (secao 14) so roda apos essa confirmacao.
channelId: { __rl: true, mode: 'id', value: "={{ $('Code - Periodo e Queries SQL').first().json.config.SLACK_DESTINATION }}" },
text: '={{ $json.mensagem_v2 }}',
otherOptions: { includeLinkToWorkflow: false },
},
credentials: SLACK_CRED,
},
{
id: 'code-preparar-log-envio-sucesso',
name: 'Code - Preparar Log de Envio (Sucesso)',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4680, 100],
parameters: {
jsCode:
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
'const envio = relatorio.envio;\n' +
'return [{ json: {\n' +
' send_key: envio.send_key,\n' +
' report_type: envio.report_type,\n' +
' period_start: envio.period_start,\n' +
' period_end: envio.period_end,\n' +
' destination: envio.destino,\n' +
' message_hash: envio.message_hash,\n' +
' trigger_type: envio.trigger_type,\n' +
" status: 'success',\n" +
' sent_at: new Date().toISOString(),\n' +
'} }];\n',
},
},
{
id: 'dt-registrar-log-envio-sucesso',
name: 'Data Table - Registrar Log de Envio (Sucesso)',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4940, 100],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_SEND_LOG,
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'code-preparar-log-envio-falha',
name: 'Code - Preparar Log de Envio (Falha)',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [4680, 300],
parameters: {
jsCode:
"const relatorio = $('Code - Motor V2: Relatorio Completo').first().json;\n" +
'const envio = relatorio.envio;\n' +
'const erro = $input.first().json.error || null;\n' +
'return [{ json: {\n' +
' send_key: envio.send_key,\n' +
' report_type: envio.report_type,\n' +
' period_start: envio.period_start,\n' +
' period_end: envio.period_end,\n' +
' destination: envio.destino,\n' +
' message_hash: envio.message_hash,\n' +
' trigger_type: envio.trigger_type,\n' +
" status: 'failed',\n" +
' sent_at: new Date().toISOString(),\n' +
' erro: erro ? String((erro && erro.message) || erro) : null,\n' +
'} }];\n',
},
},
{
id: 'dt-registrar-log-envio-falha',
name: 'Data Table - Registrar Log de Envio (Falha)',
type: 'n8n-nodes-base.dataTable',
typeVersion: 1.1,
position: [4940, 300],
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: DATATABLE_SEND_LOG,
// "erro" nao faz parte do schema fixo da Data Table (imutavel apos
// criacao) — autoMapInputData ignora campos sem coluna correspondente,
// entao o detalhe do erro fica so no log/execucao do n8n, nao na tabela.
columns: { mappingMode: 'autoMapInputData', value: {}, matchingColumns: [], schema: [] },
options: {},
},
},
{
id: 'set-envio-suprimido',
name: 'Set - Envio Suprimido (Shadow/Dev Mode)',
name: 'Set - Envio Suprimido (Guarda/Duplicata)',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [4420, 400],
position: [4420, 440],
parameters: {
assignments: {
assignments: [
setAssignment('envio_suprimido', 'envio_suprimido', 'boolean', true),
setAssignment(
'motivo',
'motivo',
'string',
'=Envio real bloqueado: SHADOW_MODE={{ $json.config.SHADOW_MODE }}, DEV_MODE={{ $json.config.DEV_MODE }}'
),
setAssignment('motivo', 'motivo', 'string', '={{ $json.envio.duplicata_bloqueada ? "BLOCKED_DUPLICATE: " + $json.envio.send_key : $json.envio.motivo_guarda }}'),
],
},
options: { includeOtherFields: true },
@@ -358,7 +493,8 @@ module.exports = function build(codeBodies) {
'MySQL - Baseline Diario Categoria': mainTo('MySQL - Baseline Diario Cliente'),
'MySQL - Baseline Diario Cliente': mainTo('Data Table - Buscar Memoria de Insights'),
'Data Table - Buscar Memoria de Insights': mainTo('Data Table - Buscar Historico Anterior'),
'Data Table - Buscar Historico Anterior': mainTo('Code - Preparar Contexto IA'),
'Data Table - Buscar Historico Anterior': mainTo('Data Table - Buscar Log de Envios'),
'Data Table - Buscar Log de Envios': mainTo('Code - Preparar Contexto IA'),
'Code - Preparar Contexto IA': mainTo('IF - IA Habilitada?'),
'IF - IA Habilitada?': {
main: [
@@ -382,10 +518,18 @@ module.exports = function build(codeBodies) {
'Code - Preparar Historico': mainTo('Data Table - Insert Historico do Periodo'),
'IF - Pode Enviar (fora de Shadow/Dev)?': {
main: [
[{ node: 'HTTP - Slack Enviar Mensagem V2', type: 'main', index: 0 }],
[{ node: 'Set - Envio Suprimido (Shadow/Dev Mode)', type: 'main', index: 0 }],
[{ node: 'Slack - Enviar Mensagem V2', type: 'main', index: 0 }],
[{ node: 'Set - Envio Suprimido (Guarda/Duplicata)', type: 'main', index: 0 }],
],
},
'Slack - Enviar Mensagem V2': {
main: [
[{ node: 'Code - Preparar Log de Envio (Sucesso)', type: 'main', index: 0 }],
[{ node: 'Code - Preparar Log de Envio (Falha)', type: 'main', index: 0 }],
],
},
'Code - Preparar Log de Envio (Sucesso)': mainTo('Data Table - Registrar Log de Envio (Sucesso)'),
'Code - Preparar Log de Envio (Falha)': mainTo('Data Table - Registrar Log de Envio (Falha)'),
};
const workflow = {
+16
View File
@@ -46,6 +46,10 @@ function runCodeNode(nodeName, inputItems, extraGlobals) {
first: () => inputItems[0],
all: () => inputItems,
},
// $execution.mode: 'trigger' simula o Schedule Trigger (producao real);
// o smoke test usa isso por padrao porque e o caminho mais exercitado
// pelo relatorio (execucao manual e coberta por sendGuard.test.js).
$execution: { mode: 'trigger' },
console,
Date,
JSON,
@@ -87,6 +91,9 @@ const configItem = [
MAX_REPORT_INSIGHTS: 5,
DEV_MODE: true,
SHADOW_MODE: true,
SEND_ENABLED: true,
FORCE_SEND: false,
SLACK_DESTINATION: 'C0000TESTE',
EXTENDED_METRICS_ENABLED: false,
ANOMALY_MULTIPLIER: 2.0,
ANOMALY_MIN_DELTA: 3,
@@ -155,6 +162,7 @@ registry.set('MySQL - Baseline Diario Cliente', [
].map((r) => ({ json: r })));
registry.set('Data Table - Buscar Memoria de Insights', []);
registry.set('Data Table - Buscar Historico Anterior', []);
registry.set('Data Table - Buscar Log de Envios', []);
console.log('\n--- 2) Code - Preparar Contexto IA ---');
const out2 = runCodeNode('Code - Preparar Contexto IA', [{ json: {} }]);
@@ -193,6 +201,7 @@ const out4 = runCodeNode('Code - Motor V2: Relatorio Completo', [{ json: out3[0]
const relatorio = out4[0].json;
console.log('status_operacional:', relatorio.status_operacional);
console.log('insights_aceitos:', relatorio.insights_aceitos.length);
console.log('envio:', JSON.stringify(relatorio.envio, null, 2));
console.log('observabilidade:', JSON.stringify(relatorio.observabilidade, null, 2));
console.log('\n=== mensagem_v1 (fallback / legado) ===\n' + relatorio.mensagem_v1);
console.log('\n=== mensagem_v2 (V2 curta) ===\n' + relatorio.mensagem_v2);
@@ -201,4 +210,11 @@ console.log('\n--- 5) Code - Preparar Historico ---');
const out5 = runCodeNode('Code - Preparar Historico', [{ json: relatorio }]);
console.log(JSON.stringify(out5[0].json, null, 2));
console.log('\n--- 6) Code - Preparar Log de Envio (Sucesso/Falha) ---');
registry.set('Code - Motor V2: Relatorio Completo', [{ json: relatorio }]);
const out6a = runCodeNode('Code - Preparar Log de Envio (Sucesso)', [{ json: {} }]);
console.log('log sucesso:', JSON.stringify(out6a[0].json));
const out6b = runCodeNode('Code - Preparar Log de Envio (Falha)', [{ json: { error: { message: 'timeout simulado' } } }]);
console.log('log falha:', JSON.stringify(out6b[0].json));
console.log('\nSMOKE TEST OK — todos os Code nodes gerados executaram sem excecao.');