const { all, audit, now, one, run } = require('../db'); const { createAsset, updateAsset } = require('./assets'); const TIMEOUT_MS = 20000; // Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow // column names on the configured CI class; requested with display values so that // reference fields (manufacturer, location, model) arrive as readable strings. const DEFAULT_CMDB_MAP = { asset_id: 'asset_tag', description: 'name', serial_number: 'serial_number', category: 'sys_class_name', vendor: 'manufacturer', location: 'location', department: 'department', custodian: 'assigned_to', acquisition_cost: 'cost', acquired_date: 'purchase_date', notes: 'short_description' }; function readSettings() { const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'"); return Object.fromEntries(rows.map((row) => [row.key, row.value])); } function parseMap(value) { if (!value) return { ...DEFAULT_CMDB_MAP }; try { const parsed = typeof value === 'string' ? JSON.parse(value) : value; return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP }; } catch { return { ...DEFAULT_CMDB_MAP }; } } // Raw config (includes password) — for making API calls only. function getConfig() { const s = readSettings(); return { instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''), username: s.servicenow_username || '', password: s.servicenow_password || '', incidentTable: s.servicenow_incident_table || 'incident', assignmentGroup: s.servicenow_assignment_group || '', callerId: s.servicenow_caller_id || '', ticketEnabled: s.servicenow_ticket_enabled === 'true', autoTicket: s.servicenow_auto_ticket === 'true', cmdbTable: s.servicenow_cmdb_table || 'cmdb_ci_hardware', cmdbQuery: s.servicenow_cmdb_query || '', cmdbLimit: Number(s.servicenow_cmdb_limit || 200), cmdbMap: parseMap(s.servicenow_cmdb_map), lastSyncAt: s.servicenow_last_sync_at || '', lastSyncStatus: s.servicenow_last_sync_status || '' }; } // Safe config for the API/UI — password is never returned, only whether it is set. function publicConfig() { const c = getConfig(); return { servicenow_instance_url: c.instanceUrl, servicenow_username: c.username, servicenow_password_set: Boolean(c.password), servicenow_incident_table: c.incidentTable, servicenow_assignment_group: c.assignmentGroup, servicenow_caller_id: c.callerId, servicenow_ticket_enabled: c.ticketEnabled, servicenow_auto_ticket: c.autoTicket, servicenow_cmdb_table: c.cmdbTable, servicenow_cmdb_query: c.cmdbQuery, servicenow_cmdb_limit: c.cmdbLimit, servicenow_cmdb_map: c.cmdbMap, servicenow_last_sync_at: c.lastSyncAt, servicenow_last_sync_status: c.lastSyncStatus, configured: Boolean(c.instanceUrl && c.username) }; } function setValue(key, value) { run( 'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [key, String(value == null ? '' : value), now()] ); } function saveConfig(body, userId) { if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, '')); if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || ''); if (body.servicenow_password_clear) setValue('servicenow_password', ''); else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password); if (body.servicenow_incident_table !== undefined) setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident'); if (body.servicenow_assignment_group !== undefined) setValue('servicenow_assignment_group', body.servicenow_assignment_group || ''); if (body.servicenow_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || ''); if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false'); if (body.servicenow_auto_ticket !== undefined) setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false'); if (body.servicenow_cmdb_table !== undefined) setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware'); if (body.servicenow_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || ''); if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200); if (body.servicenow_cmdb_map !== undefined) { const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {}); setValue('servicenow_cmdb_map', value.trim() ? value : ''); } audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined }); return publicConfig(); } function ensureConfigured(c) { if (!c.instanceUrl || !c.username) { throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 }); } } function authHeader(c) { return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`; } async function snFetch(c, pathAndQuery, options = {}) { const url = `${c.instanceUrl}${pathAndQuery}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let response; try { response = await fetch(url, { ...options, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) }, signal: controller.signal }); } catch (error) { throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 }); } finally { clearTimeout(timer); } const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = null; } if (!response.ok) { const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`; throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 }); } return body; } async function testConnection(userId) { const c = getConfig(); ensureConfigured(c); await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' }); audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl }); return { ok: true, instance: c.instanceUrl }; } // MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low). function severityToImpactUrgency(severity) { if (severity === 'critical') return { impact: '1', urgency: '1' }; if (severity === 'warning') return { impact: '2', urgency: '2' }; return { impact: '3', urgency: '3' }; } function incidentUrl(c, sysId) { return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`; } async function createIncidentForAlert(alert, userId, c = getConfig()) { ensureConfigured(c); const { impact, urgency } = severityToImpactUrgency(alert.severity); const descriptionLines = [ alert.message || '', '', `MixedAssets alert #${alert.id} (${alert.type})`, alert.asset_code ? `Asset: ${alert.asset_code}` : null, alert.due_date ? `Due: ${alert.due_date}` : null ].filter((line) => line !== null); const payload = { short_description: alert.title, description: descriptionLines.join('\n'), impact, urgency }; if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup; if (c.callerId) payload.caller_id = c.callerId; const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) }); const result = data?.result || {}; const url = result.sys_id ? incidentUrl(c, result.sys_id) : null; run( 'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?', [result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id] ); audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number }); return { sys_id: result.sys_id, number: result.number, url }; } function alertWithCode(alertId) { return one( `SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`, [alertId] ); } // Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised). async function submitTicket(alertId, userId) { const c = getConfig(); ensureConfigured(c); const alert = alertWithCode(alertId); if (!alert) return null; if (alert.external_ticket_id) { return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true }; } return createIncidentForAlert(alert, userId, c); } // Auto-create incidents for new critical alerts during the alert cycle (best-effort). async function autoTicketAlerts(alerts, userId) { const c = getConfig(); if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 }; let created = 0; for (const alert of alerts) { if (alert.severity !== 'critical' || alert.external_ticket_id) continue; try { await createIncidentForAlert(alert, userId, c); created += 1; } catch { // best-effort: a failed ticket never breaks the alert cycle } } return { created }; } // ---- CMDB pull ------------------------------------------------------------- function parseCost(value) { if (value == null || value === '') return undefined; const num = Number(String(value).replace(/[^0-9.\-]/g, '')); return Number.isFinite(num) ? num : undefined; } function parseDate(value) { if (!value) return undefined; const match = String(value).match(/\d{4}-\d{2}-\d{2}/); return match ? match[0] : undefined; } function mapCi(ci, map) { const asset = {}; for (const [assetField, ciField] of Object.entries(map)) { if (!ciField) continue; let value = ci[ciField]; if (value == null || value === '') continue; value = String(value).trim(); if (!value) continue; if (assetField === 'acquisition_cost') { const cost = parseCost(value); if (cost !== undefined) asset.acquisition_cost = cost; } else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') { const date = parseDate(value); if (date) asset[assetField] = date; } else { asset[assetField] = value; } } return asset; } function findExistingAsset(sysId, mapped) { if (sysId) { const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]); if (bySysId) return bySysId.id; } if (mapped.serial_number) { const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]); if (bySerial) return bySerial.id; } if (mapped.asset_id) { const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]); if (byCode) return byCode.id; } return null; } async function syncCmdb(userId) { const c = getConfig(); ensureConfigured(c); const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])]; const params = new URLSearchParams({ sysparm_limit: String(c.cmdbLimit || 200), sysparm_display_value: 'true', sysparm_exclude_reference_link: 'true', sysparm_fields: fields.join(',') }); if (c.cmdbQuery) params.set('sysparm_query', c.cmdbQuery); let data; try { data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' }); } catch (error) { setValue('servicenow_last_sync_at', now()); setValue('servicenow_last_sync_status', `Failed: ${error.message}`); throw error; } const cis = data?.result || []; let created = 0; let updated = 0; let skipped = 0; for (const ci of cis) { const sysId = ci.sys_id || null; const mapped = mapCi(ci, c.cmdbMap); if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; } const existingId = findExistingAsset(sysId, mapped); if (existingId) { updateAsset(existingId, mapped, userId); if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]); updated += 1; } else { const asset = createAsset({ ...mapped, source: 'servicenow' }, userId); if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]); created += 1; } } const status = `Synced ${cis.length} CI(s): ${created} created, ${updated} updated${skipped ? `, ${skipped} skipped` : ''}.`; setValue('servicenow_last_sync_at', now()); setValue('servicenow_last_sync_status', status); audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped }); return { created, updated, skipped, total: cis.length, status }; } module.exports = { DEFAULT_CMDB_MAP, autoTicketAlerts, getConfig, publicConfig, saveConfig, submitTicket, syncCmdb, testConnection };