godcrm/backend/services/AutomationTriggerService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

608 lines
21 KiB
JavaScript

// AutomationTriggerService.js
// Ticket #43305: Fire automation triggers on row creation
// Looks up active automations and executes corresponding actions
import crypto from 'crypto';
import { dbAll, dbGet, dbRun } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
import { enrichSkill } from './SkillEnrichmentService.js';
import { sendMessage, sendAdminAlert } from './TelegramService.js';
import { executeTicketRouting } from './schedule-trigger/pipeline-executors.js';
/**
* Find automation tables (automations_list and automation_logs_list)
* for the space that contains the given table.
* Same pattern as findOrCreateAutomationTables in automations.js,
* but read-only — does not create tables if they don't exist.
*
* @param {number} tableId - The table ID to find automations for
* @returns {Promise<{automationsTableId: number, logsTableId: number}|null>}
*/
async function findAutomationTables(tableId) {
// Get project and space for this table
const table = await dbGet('SELECT project_id FROM universal_tables WHERE id = ?', [tableId]);
if (!table) return null;
const project = await dbGet('SELECT space_id FROM projects WHERE id = ?', [table.project_id]);
if (!project) return null;
// Find System Data project in this space
const systemDataProject = await dbGet(
"SELECT id FROM projects WHERE space_id = ? AND name = 'System Data'",
[project.space_id]
);
if (!systemDataProject) return null;
// Find automations_list table
const automationsTable = await dbGet(
"SELECT id FROM universal_tables WHERE project_id = ? AND name = 'automations_list'",
[systemDataProject.id]
);
if (!automationsTable) return null;
// Find automation_logs_list table
const logsTable = await dbGet(
"SELECT id FROM universal_tables WHERE project_id = ? AND name = 'automation_logs_list'",
[systemDataProject.id]
);
if (!logsTable) return null;
return {
automationsTableId: automationsTable.id,
logsTableId: logsTable.id
};
}
/**
* Get active automations for a table with a specific trigger type
*
* @param {number} automationsTableId - The automations_list table ID
* @param {number} tableId - The target table ID to filter by
* @param {string} triggerType - The trigger type to filter by (e.g., 'row_create')
* @returns {Promise<Array>} Active automations matching the criteria
*/
async function getActiveAutomations(automationsTableId, tableId, triggerType) {
const rows = await dbAll(
'SELECT id, data FROM table_rows WHERE table_id = ? ORDER BY created_at DESC',
[automationsTableId]
);
return rows
.map(row => {
const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
return { id: row.id, ...data };
})
.filter(a =>
a.table_id === tableId &&
a.trigger_type === triggerType &&
a.is_active !== false &&
a.is_active !== 0
)
.map(a => ({
...a,
trigger_config: typeof a.trigger_config === 'string'
? JSON.parse(a.trigger_config || '{}')
: (a.trigger_config || {}),
action_config: typeof a.action_config === 'string'
? JSON.parse(a.action_config || '{}')
: (a.action_config || {})
}));
}
/**
* Log an automation execution result to the automation_logs_list table
*
* @param {number} logsTableId - The automation_logs_list table ID
* @param {Object} logEntry - Log data
*/
async function logAutomationExecution(logsTableId, logEntry) {
try {
const now = new Date().toISOString();
const logBaseId = `autolog_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
const logData = {
automation_id: logEntry.automationId,
automation_name: logEntry.automationName,
row_id: logEntry.rowId,
status: logEntry.status,
trigger_data: JSON.stringify(logEntry.triggerData || {}),
result_data: JSON.stringify(logEntry.resultData || {}),
error_message: logEntry.errorMessage || null,
duration_ms: logEntry.durationMs,
executed_at: now
};
await dbRun(
'INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[logsTableId, logBaseId, JSON.stringify(logData), now, now]
);
} catch (err) {
apiLogger.error({ err, logEntry }, '[AutomationTrigger] Failed to write automation log');
}
}
/**
* Update automation run count and last_run timestamp
*
* @param {number} automationRowId - The automation row ID in table_rows
* @param {Object} automationData - Current automation data
*/
async function updateAutomationRunStats(automationRowId, automationData) {
try {
const now = new Date().toISOString();
automationData.run_count = (automationData.run_count || 0) + 1;
automationData.last_run = now;
await dbRun(
'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?',
[JSON.stringify(automationData), now, automationRowId]
);
} catch (err) {
apiLogger.error({ err, automationRowId }, '[AutomationTrigger] Failed to update automation run stats');
}
}
/**
* Execute the ai_enrich action: call SkillEnrichmentService and update the row
*
* @param {number} tableId - The table the row belongs to
* @param {number} rowId - The row ID to enrich
* @param {Object} rowData - The current row data
* @returns {Promise<Object>} Result with success/error
*/
async function executeAiEnrich(tableId, rowId, rowData) {
const result = await enrichSkill(rowData);
if (!result.success) {
return { success: false, error: result.error };
}
// Merge enrichment into existing row data
const enrichedData = {
...rowData,
tags: result.enrichment.tags,
risk_level: result.enrichment.risk_level,
rating: result.enrichment.rating,
category: result.enrichment.category,
platform: result.enrichment.platform
};
// Update the row in the database
const now = new Date().toISOString();
await dbRun(
'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?',
[JSON.stringify(enrichedData), now, rowId]
);
return {
success: true,
enrichment: result.enrichment,
durationMs: result.durationMs
};
}
/**
* Execute a webhook action (same pattern as automations.js)
*
* @param {Object} config - Action config with url, method, headers
* @param {Object} rowData - Row data to send
* @returns {Promise<Object>} Result
*/
async function executeWebhook(config, rowData) {
try {
const response = await fetch(config.url, {
method: config.method || 'POST',
headers: {
'Content-Type': 'application/json',
...(config.headers || {})
},
body: JSON.stringify({
data: rowData,
timestamp: new Date().toISOString()
})
});
return {
success: response.ok,
status: response.status,
statusText: response.statusText
};
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Execute an update_field action
*
* @param {number} tableId - Table ID
* @param {number} rowId - Row ID
* @param {Object} config - Action config with column_id and value
* @returns {Promise<Object>} Result
*/
async function executeUpdateField(tableId, rowId, config) {
try {
const { column_id, value } = config;
const row = await dbGet('SELECT id, data FROM table_rows WHERE id = ?', [rowId]);
if (!row) {
return { success: false, error: 'Row not found' };
}
const data = typeof row.data === 'string' ? JSON.parse(row.data || '{}') : (row.data || {});
data[column_id] = value;
await dbRun(
'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?',
[JSON.stringify(data), new Date().toISOString(), rowId]
);
return { success: true, updated: { [column_id]: value } };
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Evaluate conditions against row data
* @param {Array} conditions - Array of { field, operator, value }
* @param {Object} rowData - Row data to evaluate
* @returns {{ pass: boolean, failedCondition?: string }}
*/
function evaluateConditions(conditions, rowData) {
if (!conditions || !Array.isArray(conditions) || conditions.length === 0) {
return { pass: true };
}
for (const cond of conditions) {
const value = rowData[cond.field];
const expected = cond.value;
const strValue = String(value || '').toLowerCase();
const strExpected = String(expected || '').toLowerCase();
let passes = false;
switch (cond.operator) {
case 'equals':
passes = strValue === strExpected;
break;
case 'not_equals':
passes = strValue !== strExpected;
break;
case 'contains':
passes = strValue.includes(strExpected);
break;
case 'not_contains':
passes = !strValue.includes(strExpected);
break;
case 'is_empty':
passes = !value || value === '' || value === null || value === undefined;
break;
case 'is_not_empty':
passes = !!value && value !== '' && value !== null;
break;
case 'greater_than':
passes = Number(value) > Number(expected);
break;
case 'less_than':
passes = Number(value) < Number(expected);
break;
default:
passes = true;
}
if (!passes) {
return { pass: false, failedCondition: `${cond.field} ${cond.operator} ${expected} (actual: ${value})` };
}
}
return { pass: true };
}
/**
* Execute a create_row action — supports both flat and array field mapping formats.
*
* Flat format: { target_table_id, field_mapping: { targetField: "sourceField" } }
* Array format: { targetTableId, fieldMappings: [{ sourceColumnId, targetColumnId, staticValue }], conditions: [...] }
*
* @param {Object} config - Action config
* @param {Object} sourceRowData - Source row data
* @param {number} [sourceRowId] - Optional source row ID for back-linking
* @returns {Promise<Object>} Result
*/
export async function executeCreateRow(config, sourceRowData, sourceRowId) {
try {
// parseInt guards against JSONB type coercion — action_config may be stored as
// a JSON string inside JSONB, causing targetTableId to arrive as a string
const rawTargetId = config.targetTableId || config.target_table_id;
const targetTableId = rawTargetId ? parseInt(rawTargetId, 10) : null;
if (!targetTableId || isNaN(targetTableId)) {
return { success: false, error: `No valid target table ID specified (got ${rawTargetId})` };
}
// Evaluate conditions first — skip row if conditions don't match
const conditions = config.conditions || config.action_conditions;
const condResult = evaluateConditions(conditions, sourceRowData);
if (!condResult.pass) {
return { success: true, skipped: true, reason: condResult.failedCondition };
}
// Build new row data from field mappings
const newData = {};
// Format 1: Array of { sourceColumnId, targetColumnId, staticValue }
const fieldMappings = config.fieldMappings;
if (Array.isArray(fieldMappings)) {
for (const mapping of fieldMappings) {
if (mapping.staticValue !== undefined) {
newData[mapping.targetColumnId] = mapping.staticValue;
} else if (mapping.sourceColumnId) {
newData[mapping.targetColumnId] = sourceRowData[mapping.sourceColumnId];
}
}
}
// Format 2: Flat object { targetField: sourceField }
const fieldMapping = config.field_mapping;
if (fieldMapping && typeof fieldMapping === 'object' && !Array.isArray(fieldMapping)) {
for (const [targetField, sourceField] of Object.entries(fieldMapping)) {
newData[targetField] = sourceRowData[sourceField];
}
}
// Static fields (separate from mappings)
if (config.static_fields && typeof config.static_fields === 'object') {
Object.assign(newData, config.static_fields);
}
// Source tracking — link created row back to source
if (sourceRowId && config.sourceRowIdField) {
newData[config.sourceRowIdField] = sourceRowId;
}
const now = new Date().toISOString();
// Idempotent upsert (opt-in): when the action declares `upsertKey` and the
// new row carries a non-empty value for it, update the existing target row
// with the same key instead of inserting a duplicate. This makes re-runs
// (e.g. blog → public mirror on every re-publish, or post-publish edits)
// propagate into the existing row rather than spawning duplicates. Without
// `upsertKey` the action keeps its original insert-only behaviour.
const upsertKey = config.upsertKey || config.upsert_key;
const upsertVal = upsertKey ? newData[upsertKey] : undefined;
if (upsertKey && upsertVal !== undefined && upsertVal !== null && upsertVal !== '') {
const existing = await dbGet(
'SELECT id, data FROM table_rows WHERE table_id = ? AND data->>? = ? LIMIT 1',
[targetTableId, upsertKey, String(upsertVal)]
);
if (existing) {
const existingData = typeof existing.data === 'string'
? JSON.parse(existing.data || '{}')
: (existing.data || {});
const merged = { ...existingData, ...newData };
await dbRun(
'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?',
[JSON.stringify(merged), now, existing.id]
);
return { success: true, updated_row_id: existing.id, upserted: true, data: merged };
}
}
const baseId = 'AUTO_' + Math.random().toString(36).substr(2, 8).toUpperCase();
const result = await dbRun(
'INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[targetTableId, baseId, JSON.stringify(newData), now, now]
);
const createdRowId = result.lastID || result.lastInsertRowid;
return { success: true, created_row_id: createdRowId, data: newData };
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Execute a notification action — supports Telegram and in-app
*
* @param {Object} config - Action config with notificationType, recipients, messageTemplate
* @param {Object} rowData - Row data for template substitution
* @returns {Promise<Object>} Result
*/
async function executeNotification(config, rowData) {
try {
const { notificationType, recipients, messageTemplate, subject } = config;
// Build message from template (replace {{field}} with rowData values)
let text = messageTemplate || JSON.stringify(rowData, null, 2);
if (messageTemplate) {
text = messageTemplate.replace(/\{\{(\w+)\}\}/g, (match, field) => {
return rowData[field] !== undefined ? String(rowData[field]) : match;
});
}
switch (notificationType) {
case 'telegram': {
// Send to each recipient (chat_id) or admin if none specified
const chatIds = recipients && recipients.length > 0 ? recipients : [];
if (chatIds.length === 0) {
const res = await sendAdminAlert(text);
return { success: res.success, type: 'telegram', target: 'admin' };
}
const results = [];
for (const chatId of chatIds) {
const res = await sendMessage(chatId, text);
results.push({ chatId, success: res.success });
}
return { success: results.every(r => r.success), type: 'telegram', results };
}
case 'email':
// TODO: Wire to SMTPService when needed
return { success: true, type: 'email', message: 'Email notification not yet wired' };
case 'slack':
return { success: true, type: 'slack', message: 'Slack notification not yet wired' };
case 'in_app':
default:
return { success: true, type: notificationType || 'in_app', message: 'In-app notification logged' };
}
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Run a single automation: condition gate + action dispatch + log + run-stats.
* Shared between row_create and row_update trigger fans-out.
*/
async function executeAutomation(automation, tableId, rowId, rowData, tables) {
const startTime = Date.now();
let result;
try {
if (automation.action_type !== 'create_row') {
const actionConditions = automation.action_config?.conditions || automation.action_config?.action_conditions;
const condCheck = evaluateConditions(actionConditions, rowData);
if (!condCheck.pass) {
result = { success: true, skipped: true, reason: condCheck.failedCondition };
const durationMs = Date.now() - startTime;
await logAutomationExecution(tables.logsTableId, {
automationId: automation.id, automationName: automation.name, rowId,
status: 'success', triggerData: rowData, resultData: result, errorMessage: null, durationMs
});
apiLogger.info({ automationId: automation.id, automationName: automation.name, skipped: true, reason: condCheck.failedCondition },
'[AutomationTrigger] Automation skipped (conditions not met)');
return;
}
}
switch (automation.action_type) {
case 'ai_enrich':
result = await executeAiEnrich(tableId, rowId, rowData);
break;
case 'webhook':
case 'n8n':
result = await executeWebhook(automation.action_config, rowData);
break;
case 'update_field':
result = await executeUpdateField(tableId, rowId, automation.action_config);
break;
case 'create_row':
result = await executeCreateRow(automation.action_config, rowData, rowId);
break;
case 'send_notification':
case 'notification':
result = await executeNotification(automation.action_config, rowData);
break;
case 'ticket_routing':
result = await executeTicketRouting(automation.action_config, rowData, rowId);
break;
default:
result = { success: false, error: 'Unknown action type: ' + automation.action_type };
}
} catch (err) {
result = { success: false, error: err.message };
}
const durationMs = Date.now() - startTime;
await logAutomationExecution(tables.logsTableId, {
automationId: automation.id,
automationName: automation.name,
rowId,
status: result.success ? 'success' : 'error',
triggerData: rowData,
resultData: result,
errorMessage: result.error || null,
durationMs
});
await updateAutomationRunStats(automation.id, automation);
apiLogger.info(
{ automationId: automation.id, automationName: automation.name, success: result.success, durationMs },
'[AutomationTrigger] Automation executed'
);
}
/**
* Fire all row_create automations for a given table and row.
* Non-blocking — call without await.
*/
export async function fireRowCreateTriggers(tableId, rowId, rowData) {
try {
const tables = await findAutomationTables(tableId);
if (!tables) return;
const automations = await getActiveAutomations(tables.automationsTableId, tableId, 'row_create');
if (automations.length === 0) return;
apiLogger.info(
{ tableId, rowId, automationCount: automations.length },
'[AutomationTrigger] Firing row_create triggers'
);
for (const automation of automations) {
await executeAutomation(automation, tableId, rowId, rowData, tables);
}
} catch (err) {
apiLogger.error({ err, tableId, rowId }, '[AutomationTrigger] Error firing row_create triggers');
}
}
/**
* Fire row_update automations for a given table and row.
* Watch-field gating prevents action loops: an automation only fires when the
* field named by trigger_config.watch_field actually changed (and, if
* trigger_config.equals is set, when the new value matches it). An
* `update_field` action that mutates a different column won't re-fire,
* because the watched field stayed the same on the second pass.
*
* trigger_config (all optional):
* - watch_field {string} — only fire when this field's value changed
* - equals {any} — only fire when newValue === equals
*
* Non-blocking — call without await.
*/
export async function fireRowUpdateTriggers(tableId, rowId, newData, oldData) {
try {
const tables = await findAutomationTables(tableId);
if (!tables) return;
const automations = await getActiveAutomations(tables.automationsTableId, tableId, 'row_update');
if (automations.length === 0) return;
const safeOld = oldData || {};
const matched = automations.filter(a => {
const cfg = a.trigger_config || {};
const field = cfg.watch_field;
if (!field) return true;
const before = safeOld[field];
const after = newData ? newData[field] : undefined;
if (before === after) return false;
if (Object.prototype.hasOwnProperty.call(cfg, 'equals')) {
// Loose equality so JSONB number/string round-trips don't miss matches.
// eslint-disable-next-line eqeqeq
return after == cfg.equals;
}
return true;
});
if (matched.length === 0) return;
apiLogger.info(
{ tableId, rowId, automationCount: matched.length },
'[AutomationTrigger] Firing row_update triggers'
);
for (const automation of matched) {
await executeAutomation(automation, tableId, rowId, newData, tables);
}
} catch (err) {
apiLogger.error({ err, tableId, rowId }, '[AutomationTrigger] Error firing row_update triggers');
}
}
export default { fireRowCreateTriggers, fireRowUpdateTriggers };