/* ServiceNow integration for DepreCore. This module provides functions to manage the configuration of the ServiceNow integration, create incidents in ServiceNow for DepreCore alerts, and synchronize assets from a ServiceNow CMDB table. It includes functions to read and save the integration settings, test the connection to ServiceNow, submit tickets for alerts, and perform a CMDB sync to import assets from ServiceNow into DepreCore. The configuration settings are stored in the app_settings table and include details such as the ServiceNow instance URL, credentials, incident table name, assignment group, caller ID, CMDB table name, and field mappings. The module also handles authentication with ServiceNow using basic auth and makes API requests to create incidents and retrieve CMDB data. The autoTicketAlerts function can be called during the alert cycle to automatically create incidents in ServiceNow for new critical alerts that do not already have an associated ticket. The syncCmdb function can be called to pull CI data from the configured ServiceNow CMDB table and create or update assets in DepreCore based on the retrieved data, using the defined field mappings to map ServiceNow CI fields to DepreCore asset fields. */ const moment = require('moment'); const { all, audit, now, one, run } = require('../db'); const { createAsset, updateAsset } = require('./assets'); const TIMEOUT_MS = 20000; console.log(`${moment().format()} 🛠️ ServiceNow integration module loaded`); // Default CMDB CI field -> DepreCore 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' }; // ---- Configuration management ------------------------------------------------ console.log(`${moment().format()} 🛠️ ServiceNow configuration management initialized`); // Read the ServiceNow integration settings from the database and return them as an object. The settings are stored in the app_settings table with keys that start with 'servicenow_'. This function retrieves all relevant settings, processes them as needed (e.g., parsing JSON for the CMDB field mapping), and returns a structured configuration object that can be used by other functions in this module to interact with the ServiceNow API and manage the integration. function readSettings() { console.log(`${moment().format()} 🔍 Reading ServiceNow integration settings from database`); const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'"); return Object.fromEntries(rows.map((row) => [row.key, row.value])); } // Parse the CMDB field mapping from a JSON string. The CMDB mapping defines how fields from the ServiceNow CMDB CI records should be mapped to fields in DepreCore when synchronizing assets. This function takes the raw value from the database (which may be a JSON string or an already parsed object), attempts to parse it if necessary, and returns a valid mapping object. If the value is missing, empty, or cannot be parsed, it returns a default mapping defined by DEFAULT_CMDB_MAP. function parseMap(value) { console.log(`${moment().format()} 🔍 Parsing ServiceNow CMDB field mapping`); if (!value) return { ...DEFAULT_CMDB_MAP }; try { // Note: we attempt to parse the CMDB mapping value as JSON if it is a string, and we validate that the parsed value is an object with keys; if the parsing fails or the resulting object is invalid, we return the default CMDB mapping; this allows us to ensure that we always have a valid mapping to work with when synchronizing assets from ServiceNow, even if the stored configuration is not set or is malformed. const parsed = typeof value === 'string' ? JSON.parse(value) : value; return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP }; console.log(`${moment().format()} ✅ ServiceNow CMDB field mapping parsed successfully`); } catch { console.log(`${moment().format()} ❌ Failed to parse ServiceNow CMDB field mapping, using default mapping`); return { ...DEFAULT_CMDB_MAP }; } console.log(`${moment().format()} ✅ ServiceNow CMDB field mapping parsed successfully`); } // Raw config (includes password) — for making API calls only. function getConfig() { console.log(`${moment().format()} 🔍 Retrieving ServiceNow integration configuration`); 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() { console.log(`${moment().format()} 🔍 Retrieving public ServiceNow integration configuration`); 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) }; } // Update a single configuration value in the database. This function is used by the saveConfig function to update individual settings for the ServiceNow integration. It uses an upsert query to insert a new setting if it does not exist or update the existing setting if it does, ensuring that the app_settings table always has the correct values for the ServiceNow configuration. function setValue(key, value) { console.log(`${moment().format()} 📝 Setting ServiceNow integration config value: ${key} = ${value ? '[set]' : '[empty]'}`); 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()] ); } // Save the ServiceNow integration settings from the provided body object. This function takes the input from an API request (e.g., from a settings form in the UI), updates the relevant configuration values in the database using the setValue function, and returns the updated public configuration. It also audits the update action for tracking purposes. The body object may contain various fields related to the ServiceNow integration, and this function ensures that they are properly saved and that sensitive information like passwords is handled securely. function saveConfig(body, userId) { console.log(`${moment().format()} 🛠️ Saving ServiceNow integration configuration`); if (body.servicenow_instance_url !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow instance URL`); setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, '')); } if (body.servicenow_username !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow username`); setValue('servicenow_username', body.servicenow_username || ''); } if (body.servicenow_password_clear) { console.log(`${moment().format()} 📝 Clearing ServiceNow password`); setValue('servicenow_password', ''); } else if (body.servicenow_password) { console.log(`${moment().format()} 📝 Setting ServiceNow password`); setValue('servicenow_password', body.servicenow_password); } if (body.servicenow_incident_table !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow incident table`); setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident'); } if (body.servicenow_assignment_group !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow assignment group`); setValue('servicenow_assignment_group', body.servicenow_assignment_group || ''); } if (body.servicenow_caller_id !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow caller ID`); setValue('servicenow_caller_id', body.servicenow_caller_id || ''); } if (body.servicenow_ticket_enabled !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow ticket enabled`); setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false'); } if (body.servicenow_auto_ticket !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow auto ticket`); setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false'); } if (body.servicenow_cmdb_table !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow CMDB table`); setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware'); } if (body.servicenow_cmdb_query !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow CMDB query`); setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || ''); } if (body.servicenow_cmdb_limit !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow CMDB limit`); setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200); } if (body.servicenow_cmdb_map !== undefined) { console.log(`${moment().format()} 📝 Setting ServiceNow CMDB map`); 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 : ''); } console.log(`${moment().format()} 📝 Updating ServiceNow integration settings`); audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined }); console.log(`${moment().format()} ✅ ServiceNow integration configuration saved successfully`); return publicConfig(); } // Ensure that the ServiceNow integration is properly configured before making API calls. This function checks that the required configuration values (instance URL and username) are present, and if not, it logs an error message and throws an error indicating that the configuration is incomplete. This helps prevent attempts to make API calls to ServiceNow without the necessary configuration, which would result in failed requests and unclear errors. function ensureConfigured(c) { console.log(`${moment().format()} 🔍 Ensuring ServiceNow integration is properly configured`); if (!c.instanceUrl || !c.username) { console.log(`${moment().format()} ❌ ServiceNow integration is not properly configured: Missing instance URL or username`); throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 }); } } // return the value for the Authorization header for ServiceNow API requests, using basic authentication with the provided credentials. This function takes the configuration object (which includes the username and password), constructs the basic auth string by encoding the credentials in base64, and returns the complete Authorization header value that can be included in API requests to authenticate with the ServiceNow instance. function authHeader(c) { console.log(`${moment().format()} 🔍 Constructing ServiceNow API Authorization header`); return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`; } // Make a fetch request to the ServiceNow API with the given path and options, including authentication and error handling. This function constructs the full URL for the API request based on the instance URL and the provided path, sets up an AbortController to enforce a timeout on the request, and includes the necessary headers for authentication and content type. It then performs the fetch request, processes the response, and handles any errors that may occur during the request or if the response indicates a failure (e.g., non-OK status). The function returns the parsed JSON response body if the request is successful. async function snFetch(c, pathAndQuery, options = {}) { console.log(`${moment().format()} 🔍 Making ServiceNow API request to: ${pathAndQuery} with options: ${JSON.stringify(options)}`); const url = `${c.instanceUrl}${pathAndQuery}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let response; try { // Note: we attempt to make the API request to ServiceNow using fetch with the constructed URL and options, including the Authorization header for authentication; if the request fails (e.g., due to network issues, timeout, or other errors), we catch the error, log an error message with details about the failure, and throw a new error indicating that the ServiceNow request failed, along with the original error message for context; this allows us to provide clear feedback about why the API request failed and helps users troubleshoot any issues with their ServiceNow integration or connectivity. response = await fetch(url, { ...options, headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) }, signal: controller.signal }); } catch (error) { console.log(`${moment().format()} ❌ ServiceNow API request failed: ${error.message}`); throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 }); } finally { console.log(`${moment().format()} ⏱️ ServiceNow API request completed, clearing timeout`); 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}`; console.log(`${moment().format()} ❌ ServiceNow API request failed: ${detail}`); throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 }); } console.log(`${moment().format()} ✅ ServiceNow API request successful: ${url}`); return body; } // Test the connection to ServiceNow by making a simple API request to retrieve one record from the incident table. This function ensures that the integration is properly configured, attempts to make an authenticated API request to ServiceNow, and if successful, it audits the test action and returns a success response; if the request fails, it throws an error with details about the failure. This function can be used in an API endpoint to allow users to verify that their ServiceNow integration settings are correct and that the application can successfully connect to their ServiceNow instance. async function testConnection(userId) { console.log(`${moment().format()} 🚀 Testing ServiceNow connection`); 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 }); console.log(`${moment().format()} ✅ ServiceNow connection test successful for instance: ${c.instanceUrl}`); return { ok: true, instance: c.instanceUrl }; } // DepreCore severity -> ServiceNow impact/urgency (1 high … 3 low). function severityToImpactUrgency(severity) { console.log(`${moment().format()} 🔍 Mapping DepreCore alert severity to ServiceNow impact and urgency: ${severity}`); if (severity === 'critical') return { impact: '1', urgency: '1' }; // highest priority if (severity === 'warning') return { impact: '2', urgency: '2' }; // medium priority return { impact: '3', urgency: '3' }; // lowest priority } // Build the payload for creating a ServiceNow incident based on a DepreCore alert. This function takes an alert object and the ServiceNow configuration, and constructs the appropriate payload for the ServiceNow API to create an incident. It maps the alert severity to the corresponding impact and urgency values expected by ServiceNow, and it formats the description to include relevant information from the alert, such as the message, asset code, and due date. function incidentUrl(c, sysId) { console.log(`${moment().format()} 🔍 Constructing ServiceNow incident URL for sys_id: ${sysId}`); return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`; } // Create a ServiceNow incident for a given DepreCore alert. This function checks that the integration is properly configured, builds the payload for the incident based on the alert details, and makes an API request to ServiceNow to create the incident. It then updates the alert in the database with the external ticket information (ID, number, URL) and audits the ticket creation action. The function returns the details of the created incident, including the sys_id, number, and URL. async function createIncidentForAlert(alert, userId, c = getConfig()) { console.log(`${moment().format()} 🚀 Creating ServiceNow incident for alert ID: ${alert.id}`); ensureConfigured(c); // Verify that the ServiceNow integration is properly configured before attempting to create an incident; if the configuration is incomplete, this will throw an error and prevent the API call from being made, which helps avoid failed requests and provides clear feedback about what needs to be configured. const { impact, urgency } = severityToImpactUrgency(alert.severity); const descriptionLines = [ alert.message || '', '', `DepreCore 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) { // Note: if an assignment group is configured in the ServiceNow integration settings, we include it in the payload for the incident creation request; this allows the created incident to be automatically assigned to the specified group in ServiceNow, which can help ensure that it is routed to the appropriate team for handling. console.log(`${moment().format()} 📝 Setting ServiceNow incident assignment group: ${c.assignmentGroup}`); payload.assignment_group = c.assignmentGroup; } if (c.callerId) { // Note: if a caller ID is configured in the ServiceNow integration settings, we include it in the payload for the incident creation request; this allows the created incident to have the specified caller information in ServiceNow, which can help provide context about who or what is associated with the incident. console.log(`${moment().format()} 📝 Setting ServiceNow incident caller ID: ${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; // Note: after successfully creating the incident in ServiceNow, we update the corresponding alert in the database with the external ticket information (sys_id, number, URL) so that we can track the association between the alert and the ServiceNow incident; we also audit the ticket creation action for tracking purposes, including details about the system and incident number; this allows us to maintain a record of which alerts have associated ServiceNow incidents and provides visibility into the ticket creation process. 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 }); console.log(`${moment().format()} ✅ ServiceNow incident created successfully for alert ID: ${alert.id}, incident number: ${result.number}`); return { sys_id: result.sys_id, number: result.number, url }; } // Retrieve an alert from the database along with its associated asset code, if available. This function performs a SQL query to get the alert details and the asset code by joining the alerts table with the assets table based on the asset_id. This is used when creating a ServiceNow incident for an alert, as the asset code can be included in the incident description for additional context. function alertWithCode(alertId) { console.log(`${moment().format()} 🔍 Retrieving alert with ID: ${alertId} and associated asset code`); 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) { console.log(`${moment().format()} 🚀 Submitting ServiceNow ticket for alert ID: ${alertId}`); const c = getConfig(); ensureConfigured(c); const alert = alertWithCode(alertId); if (!alert) { console.log(`${moment().format()} ❌ Alert with ID: ${alertId} not found`); return null; } if (alert.external_ticket_id) { console.log(`${moment().format()} 📝 Reusing existing ServiceNow ticket for alert ID: ${alertId}`); return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true }; } console.log(`${moment().format()} 🚀 Creating new ServiceNow ticket for alert ID: ${alertId}`); return createIncidentForAlert(alert, userId, c); } // Auto-create incidents for new critical alerts during the alert cycle (best-effort). async function autoTicketAlerts(alerts, userId) { console.log(`${moment().format()} 🚀 Auto-ticketing ServiceNow incidents for new critical alerts`); const c = getConfig(); if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 }; // If ticketing is not enabled or auto-ticketing is disabled or ServiceNow instance URL is not configured, we skip the auto-ticketing process and return early with a count of created tickets as 0; this allows us to avoid unnecessary processing and API calls when the auto-ticketing feature is not fully configured or enabled. let created = 0; for (const alert of alerts) { if (alert.severity !== 'critical' || alert.external_ticket_id) continue; // We only want to auto-create incidents for new critical alerts that do not already have an associated ticket; if the alert severity is not critical or if there is already an external_ticket_id, we skip the auto-ticketing for that alert and move on to the next one, ensuring that we only create incidents for the appropriate alerts. console.log(`${moment().format()} 🚀 Creating new ServiceNow ticket for alert ID: ${alert.id}`); try { await createIncidentForAlert(alert, userId, c); created += 1; // We increment the count of created tickets only if the incident creation process completes successfully; if there is an error during the creation of the incident (e.g., API failure, configuration issue), we catch the error and do not increment the created count, allowing us to provide an accurate count of how many incidents were successfully created during the auto-ticketing process. } catch(error) { console.log(`${moment().format()} ❌ Failed to create ServiceNow ticket for alert ID: ${alert.id}, with error: ${error.message}`); // best-effort: a failed ticket never breaks the alert cycle } } console.log(`${moment().format()} ✅ Auto-ticketing process completed, total new incidents created: ${created}`); return { created }; } // ---- CMDB pull ------------------------------------------------------------- // Parse a cost value from the ServiceNow CMDB, removing any non-numeric characters and converting it to a number. This function takes a raw value from the CMDB (which may include currency symbols, commas, or other formatting), cleans it to extract only the numeric characters, and returns it as a number. If the resulting value is not a valid finite number, it returns undefined. This is used when mapping CMDB CI fields to DepreCore asset fields during synchronization. function parseCost(value) { console.log(`${moment().format()} 🔍 Parsing cost value from ServiceNow CMDB: ${value}`); if (value == null || value === '') { // Note: if the cost value from the ServiceNow CMDB is null, undefined, or an empty string, we return undefined to indicate that there is no valid cost value; this allows us to handle cases where the cost information is not provided or is missing without causing errors during the asset synchronization process. console.log(`${moment().format()} ❌ Invalid cost value: ${value}`); return undefined; } const num = Number(String(value).replace(/[^0-9.\-]/g, '')); console.log(`${moment().format()} 📝 Parsed cost value: ${num} from raw input: ${value}`); return Number.isFinite(num) ? num : undefined; } // Parse a date value from the ServiceNow CMDB, extracting the date portion in YYYY-MM-DD format. This function takes a raw value from the CMDB (which may include time information or be in a different format), uses a regular expression to extract the date portion, and returns it as a string in YYYY-MM-DD format. If the value is missing or does not contain a valid date, it returns undefined. This is used when mapping CMDB CI fields to DepreCore asset fields during synchronization. function parseDate(value) { console.log(`${moment().format()} 🔍 Parsing date value from ServiceNow CMDB: ${value}`); if (!value) { // Note: if the date value from the ServiceNow CMDB is null, undefined, or an empty string, we return undefined to indicate that there is no valid date value; this allows us to handle cases where the date information is not provided or is missing without causing errors during the asset synchronization process. console.log(`${moment().format()} ❌ Invalid date value: ${value}`); return undefined; } const match = String(value).match(/\d{4}-\d{2}-\d{2}/); console.log(`${moment().format()} 📝 Parsed date value: ${match ? match[0] : undefined} from raw input: ${value}`); return match ? match[0] : undefined; } // Map a ServiceNow CMDB CI record to a DepreCore asset object based on the provided field mapping. This function takes a CI record from ServiceNow and a mapping object that defines how ServiceNow fields correspond to DepreCore asset fields. It iterates through the mapping, extracts the relevant values from the CI, processes them as needed (e.g., parsing costs and dates), and constructs an asset object that can be used to create or update an asset in DepreCore during synchronization. function mapCi(ci, map) { console.log(`${moment().format()} 🔍 Mapping ServiceNow CMDB CI to DepreCore asset using mapping: ${JSON.stringify(map)}`); const asset = {}; for (const [assetField, ciField] of Object.entries(map)) { if (!ciField) continue; // If the mapping for this asset field is empty or falsy, we skip it and do not attempt to extract a value from the CI; this allows us to have optional mappings where certain asset fields may not be mapped to any CI field, and we simply ignore those during the mapping process. let value = ci[ciField]; if (value == null || value === '') continue; // If the value extracted from the CI for this field is null, undefined, or an empty string, we skip it and do not include it in the asset object; this allows us to avoid setting asset fields with invalid or empty values during the mapping process. value = String(value).trim(); if (!value) continue; // If the trimmed value is an empty string, we skip it and do not include it in the asset object; this allows us to avoid setting asset fields with empty values that may not be meaningful or valid in DepreCore. if (assetField === 'acquisition_cost') { console.log(`${moment().format()} 🔍 Parsing acquisition cost for asset field: ${assetField} from CI field: ${ciField} with raw value: ${value}`); const cost = parseCost(value); if (cost !== undefined) { // Note: if the acquisition cost value from the ServiceNow CMDB is successfully parsed into a valid number, we set it on the asset object; if the parsing fails and returns undefined, we skip setting the acquisition_cost field on the asset, allowing us to avoid setting invalid cost values during the mapping process. console.log(`${moment().format()} 📝 Parsed acquisition cost: ${cost} for asset field: ${assetField}`); asset.acquisition_cost = cost; } } else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') { // Note: for date fields such as acquired_date, in_service_date, and disposal_date, we attempt to parse the date value from the ServiceNow CMDB using the parseDate function; if a valid date is extracted, we set it on the asset object; if the parsing fails and returns undefined, we skip setting that date field on the asset, allowing us to avoid setting invalid date values during the mapping process. console.log(`${moment().format()} 🔍 Parsing date for asset field: ${assetField} from CI field: ${ciField} with raw value: ${value}`); const date = parseDate(value); if (date) asset[assetField] = date; } else { // For other fields, we simply set the trimmed value from the CI on the asset object based on the mapping; this allows us to map string fields directly from the ServiceNow CMDB to DepreCore without additional processing, while still ensuring that we only set valid, non-empty values. console.log(`${moment().format()} 📝 Mapping CI field: ${ciField} with value: ${value} to asset field: ${assetField}`); asset[assetField] = value; } } console.log(`${moment().format()} ✅ Completed mapping ServiceNow CMDB CI to DepreCore asset: ${JSON.stringify(asset)}`); return asset; } // Find an existing asset in DepreCore that corresponds to the given ServiceNow CMDB CI, based on the sys_id and mapped fields. This function attempts to find an existing asset in the database that matches the provided ServiceNow sys_id or has matching values for key fields such as serial_number or asset_id based on the mapping; this is used during synchronization to determine whether to create a new asset or update an existing one, ensuring that we maintain accurate associations between ServiceNow CIs and DepreCore assets. function findExistingAsset(sysId, mapped) { console.log(`${moment().format()} 🔍 Finding existing asset for ServiceNow CMDB CI with sys_id: ${sysId} and mapped fields: ${JSON.stringify(mapped)}`); if (sysId) { console.log(`${moment().format()} 🔍 Looking up existing asset by ServiceNow sys_id: ${sysId}`); const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]); if (bySysId) return bySysId.id; } if (mapped.serial_number) { console.log(`${moment().format()} 🔍 Looking up existing asset by serial number: ${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) { console.log(`${moment().format()} 🔍 Looking up existing asset by asset ID: ${mapped.asset_id}`); const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]); if (byCode) return byCode.id; } console.log(`${moment().format()} ❌ No existing asset found for ServiceNow CMDB CI with sys_id: ${sysId}`); return null; } // Synchronize assets from the ServiceNow CMDB based on the configured table and mapping. This function retrieves CI records from the specified ServiceNow CMDB table using the API, maps them to DepreCore asset fields based on the configured mapping, and then either creates new assets or updates existing ones in the database. It also tracks the synchronization status and audits the sync action for reporting purposes. The function returns a summary of the synchronization results, including how many assets were created, updated, skipped, and the total number of CIs processed. async function syncCmdb(userId) { console.log(`${moment().format()} 🚀 Starting synchronization of assets from ServiceNow CMDB`); 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) { console.log(`${moment().format()} ❌ Failed to fetch ServiceNow CMDB data: ${error.message}`); 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) { // Note: for each CI record retrieved from the ServiceNow CMDB, we log the processing of that CI, map it to a DepreCore asset using the configured mapping, and then determine whether to create a new asset or update an existing one based on the sys_id and key fields; if the mapped asset does not have sufficient information (e.g., missing description, asset_id, and serial_number), we skip it and do not create or update an asset for that CI; this allows us to ensure that we only synchronize CIs that have meaningful data and maintain accurate associations between ServiceNow CIs and DepreCore assets. console.log(`${moment().format()} 🔄 Processing CI: ${ci.sys_id || 'N/A'}`); 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) { // Note: if an existing asset is found that corresponds to the ServiceNow CMDB CI (based on sys_id or key fields), we update that asset with the new mapped values from the CI; if the sys_id is available, we also ensure that it is set on the asset for future reference; this allows us to keep our asset records up-to-date with the latest information from ServiceNow while maintaining the association between the CI and the asset. console.log(`${moment().format()} 🔄 Updating existing asset with ID: ${existingId} for CI sys_id: ${sysId}`); updateAsset(existingId, mapped, userId); if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]); updated += 1; } else { // Note: if no existing asset is found for the ServiceNow CMDB CI, we create a new asset in the database with the mapped values from the CI; if the sys_id is available, we set it on the new asset for future reference; this allows us to add new assets to our system based on the CIs in ServiceNow, ensuring that we have a comprehensive and up-to-date inventory of assets that correspond to the CIs in the ServiceNow CMDB. console.log(`${moment().format()} ➕ Creating new asset for CI sys_id: ${sysId}`); 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 }); console.log(`${moment().format()} ✅ ServiceNow CMDB synchronization completed. ${status}`); return { created, updated, skipped, total: cis.length, status }; } // Export the functions and constants for use in other parts of the application, such as API route handlers. This allows us to keep the ServiceNow integration logic organized in a single module while still making it accessible to other parts of the codebase that need to interact with ServiceNow for configuration, ticket creation, and CMDB synchronization. module.exports = { DEFAULT_CMDB_MAP, autoTicketAlerts, getConfig, publicConfig, saveConfig, submitTicket, syncCmdb, testConnection };