338 lines
13 KiB
JavaScript
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
|
|
};
|