Files
MixedAssets/server/services/servicenow.js
2026-06-05 04:15:24 -05:00

338 lines
13 KiB
JavaScript

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
};