Updated A LOT
This commit is contained in:
337
server/services/servicenow.js
Normal file
337
server/services/servicenow.js
Normal file
@@ -0,0 +1,337 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user