Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
2026-06-06 02:32:47 -05:00
parent dfd999b6a9
commit f024286b5e
59 changed files with 3814 additions and 299 deletions

View File

@@ -26,6 +26,8 @@ const CAPABILITIES = [
// Administration
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
{ key: 'admin.security', label: 'Manage password & lockout policy', group: 'Administration' },
{ key: 'admin.audit', label: 'View activity & audit trail', group: 'Administration' },
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
];

View File

@@ -151,14 +151,14 @@ function digestHtml(alerts) {
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
).join('');
return `<h2>MixedAssets alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
return `<h2>DepreCore alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function digestText(alerts) {
return `MixedAssets alerts (${alerts.length}):\n` +
return `DepreCore alerts (${alerts.length}):\n` +
alerts.map((a) => `- [${a.severity}] ${a.title}${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
}
@@ -185,7 +185,7 @@ async function runAlertCycle(userId) {
if (emailReady) {
await sendMail({
subject: `MixedAssets: ${pending.length} new alert(s)`,
subject: `DepreCore: ${pending.length} new alert(s)`,
html: digestHtml(pending),
text: digestText(pending)
});

View File

@@ -2,18 +2,32 @@ const { all, audit, now, one, parseJson, run } = require('../db');
const SOURCES = ['common', 'industry', 'business', 'custom'];
// How many asset categories reference a given class number (categories store it in metadata).
// How many asset categories reference a class. Categories link by unique asset_class_id; older
// records that only stored asset_class_number fall back to matching on the (non-unique) number.
function categoryUsage() {
const counts = {};
const byId = {};
const byNumber = {};
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
const num = parseJson(row.metadata, {}).asset_class_number;
if (num) counts[num] = (counts[num] || 0) + 1;
const meta = parseJson(row.metadata, {});
if (meta.asset_class_id) byId[meta.asset_class_id] = (byId[meta.asset_class_id] || 0) + 1;
else if (meta.asset_class_number) byNumber[meta.asset_class_number] = (byNumber[meta.asset_class_number] || 0) + 1;
}
return counts;
// Class numbers are not unique (e.g. 00.12 is shared by Computers, Casinos, and a dozen others). A
// category that only stored a number can't be pinned to one class, so the number fallback is only
// safe when exactly one class carries that number — otherwise it would falsely flag every sharing
// class as "assigned."
const numberOwners = {};
for (const row of all('SELECT class_number FROM asset_classes WHERE class_number IS NOT NULL')) {
numberOwners[row.class_number] = (numberOwners[row.class_number] || 0) + 1;
}
return { byId, byNumber, numberOwners };
}
function fromRow(row, usage) {
if (!row) return null;
const idCount = usage.byId[row.id] || 0;
const uniqueNumber = row.class_number && usage.numberOwners[row.class_number] === 1;
const numberCount = uniqueNumber ? (usage.byNumber[row.class_number] || 0) : 0;
return {
id: row.id,
class_number: row.class_number,
@@ -22,7 +36,7 @@ function fromRow(row, usage) {
ads: row.ads,
source: row.source,
table: row.source, // back-compat alias used by the category picker
category_count: row.class_number ? (usage[row.class_number] || 0) : 0
category_count: idCount + numberCount
};
}

View File

@@ -0,0 +1,92 @@
const { all, one } = require('../db');
// Read-only access to the system-wide audit trail captured by db.audit(). Powers the admin
// Activity & Audit Trail viewer: filtered + paginated queries, filter facets, and CSV export.
const MAX_PAGE_SIZE = 200;
// Build a parameterized WHERE clause from the supported filters.
function buildWhere(filters = {}) {
const clauses = [];
const params = [];
if (filters.actor) { clauses.push('al.actor_id = ?'); params.push(Number(filters.actor)); }
if (filters.action) { clauses.push('al.action = ?'); params.push(filters.action); }
if (filters.entity_type) { clauses.push('al.entity_type = ?'); params.push(filters.entity_type); }
if (filters.from) { clauses.push('al.created_at >= ?'); params.push(String(filters.from)); }
if (filters.to) { clauses.push('al.created_at <= ?'); params.push(`${String(filters.to)}￿`); }
if (filters.q) {
clauses.push('(al.entity_type LIKE ? OR al.entity_id LIKE ? OR al.action LIKE ? OR u.name LIKE ?)');
const like = `%${filters.q}%`;
params.push(like, like, like, like);
}
return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params };
}
const BASE = `
FROM audit_logs al
LEFT JOIN users u ON u.id = al.actor_id
`;
// Paginated query. Returns { rows, total, page, pageSize }.
function query(filters = {}, { page = 1, pageSize = 25 } = {}) {
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(pageSize) || 25));
const current = Math.max(1, Number(page) || 1);
const { where, params } = buildWhere(filters);
const total = one(`SELECT COUNT(*) AS c ${BASE} ${where}`, params).c;
const rows = all(
`SELECT al.id, al.actor_id, u.name AS actor_name, al.action, al.entity_type, al.entity_id,
al.before_json, al.after_json, al.created_at
${BASE} ${where}
ORDER BY al.id DESC
LIMIT ? OFFSET ?`,
[...params, size, (current - 1) * size]
);
return { rows, total, page: current, pageSize: size };
}
// All matching rows (no pagination) — used by CSV export. Capped to avoid unbounded memory.
function queryAll(filters = {}, limit = 50000) {
const { where, params } = buildWhere(filters);
return all(
`SELECT al.id, al.actor_id, u.name AS actor_name, al.action, al.entity_type, al.entity_id,
al.before_json, al.after_json, al.created_at
${BASE} ${where}
ORDER BY al.id DESC
LIMIT ?`,
[...params, limit]
);
}
// Distinct values for the filter dropdowns.
function facets() {
return {
actions: all('SELECT DISTINCT action FROM audit_logs ORDER BY action').map((r) => r.action),
entity_types: all('SELECT DISTINCT entity_type FROM audit_logs ORDER BY entity_type').map((r) => r.entity_type),
actors: all(
`SELECT DISTINCT al.actor_id AS id, u.name AS name
FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id
WHERE al.actor_id IS NOT NULL
ORDER BY u.name`
)
};
}
function csvCell(value) {
const s = value === null || value === undefined ? '' : String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
// Serialize rows to CSV text.
function toCsv(rows) {
const header = ['id', 'timestamp', 'actor', 'action', 'entity_type', 'entity_id', 'before', 'after'];
const lines = [header.join(',')];
for (const r of rows) {
lines.push([
r.id, r.created_at, r.actor_name || `#${r.actor_id || ''}`, r.action,
r.entity_type, r.entity_id, r.before_json, r.after_json
].map(csvCell).join(','));
}
return lines.join('\n');
}
module.exports = { facets, query, queryAll, toCsv };

View File

@@ -14,7 +14,7 @@ async function rowsFromImport(format, buffer) {
}
if (format === 'xml') {
const parsed = new XMLParser({ ignoreAttributes: false }).parse(text);
const assets = parsed.assets?.asset || parsed.MixedAssets?.assets?.asset || [];
const assets = parsed.assets?.asset || parsed.DepreCore?.assets?.asset || [];
return Array.isArray(assets) ? assets : [assets];
}
if (format === 'xlsx' || format === 'xls') {
@@ -30,7 +30,7 @@ async function assetExport(format) {
if (format === 'csv') {
return {
contentType: 'text/csv',
filename: 'mixedassets-assets.csv',
filename: 'deprecore-assets.csv',
body: Papa.unparse(rows)
};
}
@@ -51,20 +51,20 @@ async function assetExport(format) {
];
return {
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
filename: 'mixedassets-assets.xlsx',
filename: 'deprecore-assets.xlsx',
body: await writeXlsxFile(sheetData).toBuffer()
};
}
if (format === 'xml') {
return {
contentType: 'application/xml',
filename: 'mixedassets-assets.xml',
filename: 'deprecore-assets.xml',
body: new XMLBuilder({ format: true }).build({ assets: { asset: rows } })
};
}
return {
contentType: 'application/json',
filename: 'mixedassets-assets.json',
filename: 'deprecore-assets.json',
body: JSON.stringify({ exportedAt: now(), assets: rows })
};
}

View File

@@ -107,9 +107,9 @@ async function sendMail({ to, subject, html, text }) {
async function sendTestEmail(to, userId) {
const info = await sendMail({
to: to ? [to] : null,
subject: 'MixedAssets test email',
html: '<p>Your MixedAssets SMTP settings are working. This is a test message.</p>',
text: 'Your MixedAssets SMTP settings are working. This is a test message.'
subject: 'DepreCore test email',
html: '<p>Your DepreCore SMTP settings are working. This is a test message.</p>',
text: 'Your DepreCore SMTP settings are working. This is a test message.'
});
audit(userId, 'send', 'test_email', null, null, { to });
return { messageId: info.messageId, accepted: info.accepted };

View File

@@ -0,0 +1,212 @@
const bcrypt = require('bcryptjs');
const { all, audit, now, run } = require('../db');
// Configurable password & lockout policy for locally managed users. The policy lives as
// `password_*` rows in app_settings (no schema churn to tune rules), is read back through
// getPolicy(), and is enforced at every password-set point plus on login.
const DEFAULT_POLICY = {
min_length: 12,
require_upper: true,
require_lower: true,
require_number: true,
require_symbol: true,
disallow_identifiers: true, // block the user's name / email local-part inside the password
history_count: 5, // disallow reuse of the last N passwords (0 = off)
expiry_days: 90, // force a change after this many days (0 = never)
min_age_hours: 0, // minimum age before a user may change again (0 = off)
lockout_threshold: 5, // failed logins before lock (0 = off)
lockout_window_minutes: 15 // how long an account stays locked
};
// Each policy field plus how to coerce the stored string back to its typed value.
const NUMERIC_KEYS = ['min_length', 'history_count', 'expiry_days', 'min_age_hours', 'lockout_threshold', 'lockout_window_minutes'];
const BOOLEAN_KEYS = ['require_upper', 'require_lower', 'require_number', 'require_symbol', 'disallow_identifiers'];
function clampInt(value, fallback, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.round(n)));
}
function toBool(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
return value === '1' || value === 'true' || value === 1;
}
// Read the saved policy, falling back to defaults for any unset key.
function getPolicy() {
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'password_%'");
const stored = Object.fromEntries(rows.map((r) => [r.key.replace(/^password_/, ''), r.value]));
const policy = { ...DEFAULT_POLICY };
for (const key of NUMERIC_KEYS) {
if (stored[key] !== undefined) policy[key] = clampInt(stored[key], DEFAULT_POLICY[key], 0, 100000);
}
for (const key of BOOLEAN_KEYS) {
if (stored[key] !== undefined) policy[key] = toBool(stored[key], DEFAULT_POLICY[key]);
}
// min_length has a sane floor regardless of what was stored.
policy.min_length = Math.max(4, policy.min_length);
return policy;
}
// Persist a (partial) policy. Unknown keys are ignored; values are normalized through getPolicy's coercion.
function savePolicy(body, userId) {
const before = getPolicy();
const incoming = body && typeof body === 'object' ? body : {};
const ts = now();
const write = (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',
[`password_${key}`, String(value), ts]
);
for (const key of NUMERIC_KEYS) {
if (incoming[key] !== undefined) write(key, clampInt(incoming[key], DEFAULT_POLICY[key], 0, 100000));
}
for (const key of BOOLEAN_KEYS) {
if (incoming[key] !== undefined) write(key, toBool(incoming[key], DEFAULT_POLICY[key]) ? 1 : 0);
}
const after = getPolicy();
audit(userId, 'update', 'password_policy', 'app', before, after);
return after;
}
// Human-readable rules for the UI hint/preview.
function describe(policy = getPolicy()) {
const rules = [`At least ${policy.min_length} characters`];
if (policy.require_upper) rules.push('An uppercase letter (AZ)');
if (policy.require_lower) rules.push('A lowercase letter (az)');
if (policy.require_number) rules.push('A number (09)');
if (policy.require_symbol) rules.push('A symbol (e.g. !@#$%)');
if (policy.disallow_identifiers) rules.push('Must not contain your name or email');
if (policy.history_count > 0) rules.push(`Cannot reuse your last ${policy.history_count} password(s)`);
if (policy.expiry_days > 0) rules.push(`Expires every ${policy.expiry_days} days`);
if (policy.lockout_threshold > 0) rules.push(`Locks for ${policy.lockout_window_minutes} min after ${policy.lockout_threshold} failed sign-ins`);
return rules;
}
const SYMBOL_RE = /[^A-Za-z0-9]/;
// Returns an array of human-readable failure messages ([] when the password satisfies the policy).
// `user` (optional) enables identifier and history checks; pass { id, name, email }.
function checkPassword(password, user = null, policy = getPolicy()) {
const failures = [];
const value = String(password || '');
if (value.length < policy.min_length) failures.push(`Use at least ${policy.min_length} characters`);
if (policy.require_upper && !/[A-Z]/.test(value)) failures.push('Add an uppercase letter');
if (policy.require_lower && !/[a-z]/.test(value)) failures.push('Add a lowercase letter');
if (policy.require_number && !/[0-9]/.test(value)) failures.push('Add a number');
if (policy.require_symbol && !SYMBOL_RE.test(value)) failures.push('Add a symbol');
if (policy.disallow_identifiers && user) {
const haystack = value.toLowerCase();
const needles = [];
if (user.name) needles.push(...String(user.name).toLowerCase().split(/\s+/).filter((p) => p.length >= 3));
if (user.email) {
const local = String(user.email).toLowerCase().split('@')[0];
if (local && local.length >= 3) needles.push(local);
}
if (needles.some((n) => haystack.includes(n))) failures.push('Must not contain your name or email');
}
if (policy.history_count > 0 && user && user.id) {
const recent = all(
'SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?',
[user.id, policy.history_count]
);
if (recent.some((row) => bcrypt.compareSync(value, row.password_hash))) {
failures.push(`Choose a password you have not used in your last ${policy.history_count}`);
}
}
return failures;
}
// Throwing wrapper used by routes: 400s with the joined failure list.
function validatePassword(password, user = null) {
const failures = checkPassword(password, user, getPolicy());
if (failures.length) throw Object.assign(new Error(failures.join('. ')), { status: 400 });
}
// Record a newly set hash and prune the per-user history to the configured depth.
function recordPassword(userId, hash) {
run('INSERT INTO password_history (user_id, password_hash, created_at) VALUES (?, ?, ?)', [userId, hash, now()]);
const keep = Math.max(getPolicy().history_count, 1);
run(
`DELETE FROM password_history
WHERE user_id = ?
AND id NOT IN (SELECT id FROM password_history WHERE user_id = ? ORDER BY id DESC LIMIT ?)`,
[userId, userId, keep]
);
}
// ---- Account lockout ---------------------------------------------------------
function isLocked(user) {
return Boolean(user && user.locked_until && new Date(user.locked_until).getTime() > Date.now());
}
// After a failed login: bump the counter and lock the account if the threshold is reached.
// Returns { locked, lockedUntil } so the caller can shape the response.
function registerFailure(user) {
const policy = getPolicy();
const attempts = (user.failed_attempts || 0) + 1;
let lockedUntil = null;
if (policy.lockout_threshold > 0 && attempts >= policy.lockout_threshold) {
lockedUntil = new Date(Date.now() + policy.lockout_window_minutes * 60000).toISOString();
}
run('UPDATE users SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE id = ?', [
attempts, lockedUntil, now(), user.id
]);
if (lockedUntil) audit(user.id, 'account_locked', 'user', user.id, null, { until: lockedUntil, attempts });
return { locked: Boolean(lockedUntil), lockedUntil, attempts };
}
// After a successful login: clear the lockout counters and stamp last_login_at.
function registerSuccess(user) {
run('UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE id = ?', [
now(), now(), user.id
]);
}
// Admin action: clear a lock so the user can sign in again immediately.
function unlock(userId, actorId) {
run('UPDATE users SET failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE id = ?', [now(), userId]);
audit(actorId, 'account_unlocked', 'user', userId, null, null);
}
// Has this user's password aged past the expiry window?
function passwordExpired(user, policy = getPolicy()) {
if (!policy.expiry_days || !user || !user.password_changed_at) return false;
const age = Date.now() - new Date(user.password_changed_at).getTime();
return age > policy.expiry_days * 86400000;
}
// Does the policy forbid changing again this soon? Returns minutes remaining (0 when allowed).
function minAgeRemainingMinutes(user, policy = getPolicy()) {
if (!policy.min_age_hours || !user || !user.password_changed_at) return 0;
const elapsedMs = Date.now() - new Date(user.password_changed_at).getTime();
const remainingMs = policy.min_age_hours * 3600000 - elapsedMs;
return remainingMs > 0 ? Math.ceil(remainingMs / 60000) : 0;
}
// Convenience used by login to decide whether to force a change step.
function mustChangePassword(user) {
return Boolean(user.must_change_password) || passwordExpired(user);
}
module.exports = {
DEFAULT_POLICY,
checkPassword,
describe,
getPolicy,
isLocked,
minAgeRemainingMinutes,
mustChangePassword,
passwordExpired,
recordPassword,
registerFailure,
registerSuccess,
savePolicy,
unlock,
validatePassword
};

View File

@@ -0,0 +1,80 @@
/*
* Tests for the password-policy validation and lockout helpers. These exercise the pure logic
* (complexity checks, identifier detection, lockout math) against an explicit policy object so
* they do not depend on the database-backed settings store.
*/
const { checkPassword, describe: describePolicy, DEFAULT_POLICY, isLocked, minAgeRemainingMinutes, passwordExpired } = require('./passwordPolicy');
const strictPolicy = {
...DEFAULT_POLICY,
min_length: 12,
require_upper: true,
require_lower: true,
require_number: true,
require_symbol: true,
disallow_identifiers: true,
history_count: 0 // disable DB-backed history for these pure tests
};
describe('password policy', () => {
test('a compliant password passes every rule', () => {
expect(checkPassword('Str0ng!Passphrase', null, strictPolicy)).toEqual([]);
});
test('each missing character class is reported', () => {
expect(checkPassword('short', null, strictPolicy).length).toBeGreaterThan(0);
expect(checkPassword('alllowercase1!', null, strictPolicy)).toContain('Add an uppercase letter');
expect(checkPassword('ALLUPPERCASE1!', null, strictPolicy)).toContain('Add a lowercase letter');
expect(checkPassword('NoNumbersHere!', null, strictPolicy)).toContain('Add a number');
expect(checkPassword('NoSymbolsHere1', null, strictPolicy)).toContain('Add a symbol');
});
test('minimum length is enforced', () => {
expect(checkPassword('Ab1!', null, strictPolicy)).toContain('Use at least 12 characters');
});
test('the user name or email cannot appear in the password', () => {
const user = { name: 'Jordan Banks', email: 'jbanks@example.com' };
expect(checkPassword('Jordan!Secret99', user, strictPolicy)).toContain('Must not contain your name or email');
expect(checkPassword('jbanks!Secret99', user, strictPolicy)).toContain('Must not contain your name or email');
// A password without the identifiers is fine.
expect(checkPassword('Unrelated!Secret99', user, strictPolicy)).toEqual([]);
});
test('identifier check is skipped when the policy disables it', () => {
const policy = { ...strictPolicy, disallow_identifiers: false };
const user = { name: 'Jordan Banks', email: 'jbanks@example.com' };
expect(checkPassword('Jordan!Secret99', user, policy)).toEqual([]);
});
test('describe lists the active rules', () => {
const rules = describePolicy(strictPolicy);
expect(rules.some((r) => /12 characters/.test(r))).toBe(true);
expect(rules.some((r) => /uppercase/.test(r))).toBe(true);
});
test('isLocked reflects the locked_until timestamp', () => {
expect(isLocked({ locked_until: new Date(Date.now() + 60000).toISOString() })).toBe(true);
expect(isLocked({ locked_until: new Date(Date.now() - 60000).toISOString() })).toBe(false);
expect(isLocked({ locked_until: null })).toBe(false);
});
test('passwordExpired compares age against expiry_days', () => {
const policy = { ...DEFAULT_POLICY, expiry_days: 30 };
const old = new Date(Date.now() - 40 * 86400000).toISOString();
const recent = new Date(Date.now() - 5 * 86400000).toISOString();
expect(passwordExpired({ password_changed_at: old }, policy)).toBe(true);
expect(passwordExpired({ password_changed_at: recent }, policy)).toBe(false);
// expiry_days 0 means passwords never expire.
expect(passwordExpired({ password_changed_at: old }, { ...policy, expiry_days: 0 })).toBe(false);
});
test('minAgeRemainingMinutes blocks changes that are too soon', () => {
const policy = { ...DEFAULT_POLICY, min_age_hours: 24 };
const justChanged = new Date(Date.now() - 60000).toISOString();
const longAgo = new Date(Date.now() - 48 * 3600000).toISOString();
expect(minAgeRemainingMinutes({ password_changed_at: justChanged }, policy)).toBeGreaterThan(0);
expect(minAgeRemainingMinutes({ password_changed_at: longAgo }, policy)).toBe(0);
});
});

View File

@@ -1,4 +1,5 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const { computeNextDue, projectUsageDue } = require('./pmScheduling');
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
@@ -39,6 +40,34 @@ function planComponents(planId) {
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
}
// Usage meters declared on a plan template (e.g. Operating Hours / hrs / due every 500).
function planMeters(planId) {
return all('SELECT id, sort_order, label, unit, usage_interval FROM pm_plan_meters WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
}
// Per-schedule meters with their reading log, used by the engine and surfaced to the UI.
function assetPmMeters(apId) {
return all('SELECT * FROM asset_pm_meters WHERE asset_pm_id = ? ORDER BY sort_order, id', [apId]).map((m) => {
const readings = all(
'SELECT reading, reading_date FROM pm_meter_readings WHERE asset_pm_meter_id = ? ORDER BY reading_date, id',
[m.id]
);
return { ...m, readings, projected_usage_due_date: projectUsageDue(m, readings, todayStr()) };
});
}
function writePlanMeters(planId, meters) {
run('DELETE FROM pm_plan_meters WHERE plan_id = ?', [planId]);
(meters || []).forEach((meter, index) => {
const label = (meter.label || '').trim();
if (!label) return;
run(
'INSERT INTO pm_plan_meters (plan_id, sort_order, label, unit, usage_interval) VALUES (?, ?, ?, ?, ?)',
[planId, index, label, (meter.unit || '').trim() || null, Number(meter.usage_interval) || 0]
);
});
}
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
function planPhotos(planId, withData = true) {
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
@@ -58,7 +87,16 @@ function planFromRow(row, { withPhotoData = true } = {}) {
if (!row) return null;
const components = planComponents(row.id);
const photos = planPhotos(row.id, withPhotoData);
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
return {
...row,
lifespan_adjust: Boolean(row.lifespan_adjust),
steps: planSteps(row.id),
components,
components_total: componentsTotal(components),
meters: planMeters(row.id),
photos,
photo_count: photos.length
};
}
function listPlans() {
@@ -128,17 +166,18 @@ function createPlan(body, userId) {
const ts = now();
return tx(() => {
const result = run(
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
body.name || 'PM plan', body.description || null, body.category || null,
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
resolveEstimatedMinutes(body, null), body.instructions || null,
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
userId, ts, ts
]
);
writeSteps(result.lastInsertRowid, body.steps);
writeComponents(result.lastInsertRowid, body.components);
writePlanMeters(result.lastInsertRowid, body.meters);
writePhotos(result.lastInsertRowid, body.photos);
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
return getPlan(result.lastInsertRowid);
@@ -151,17 +190,20 @@ function updatePlan(id, body, userId) {
return tx(() => {
run(
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
estimated_minutes = ?, instructions = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
[
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
resolveEstimatedMinutes(body, before.estimated_minutes),
body.instructions ?? before.instructions, now(), id
body.instructions ?? before.instructions,
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
now(), id
]
);
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
if (Array.isArray(body.components)) writeComponents(id, body.components);
if (Array.isArray(body.meters)) writePlanMeters(id, body.meters);
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
return getPlan(id);
@@ -179,12 +221,14 @@ function deletePlan(id, userId) {
// ---- Per-asset PM schedules ------------------------------------------------
function assetPmRows(assetId) {
const settings = engineSettings();
return all(
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
u.name AS assigned_to_name
u.name AS assigned_to_name, a.in_service_date AS asset_in_service_date, a.useful_life_months AS asset_useful_life_months
FROM asset_pm_plans ap
LEFT JOIN pm_plans p ON p.id = ap.plan_id
LEFT JOIN users u ON u.id = ap.assigned_to
LEFT JOIN assets a ON a.id = ap.asset_id
WHERE ap.asset_id = ?
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
[assetId]
@@ -208,12 +252,25 @@ function assetPmRows(assetId) {
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
}));
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
const meters = assetPmMeters(row.id);
// Re-derive the per-signal breakdown for display (the persisted next_due_date stays authoritative).
const projection = computeNextDue(
{ lastDate: row.last_completed_date || row.start_date, frequency_value: row.frequency_value, frequency_unit: row.frequency_unit, lifespan_adjust: Boolean(row.lifespan_adjust) },
{ in_service_date: row.asset_in_service_date, useful_life_months: row.asset_useful_life_months },
meters,
settings,
todayStr()
);
return {
...row,
active: Boolean(row.active),
lifespan_adjust: Boolean(row.lifespan_adjust),
steps: row.plan_id ? planSteps(row.plan_id) : [],
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
components,
meters,
signals: projection.signals,
due_driver: projection.driver,
expected_cost: componentsTotal(components),
total_cost: totalCost,
last_cost: completions[0] ? round2(completions[0].cost) : 0,
@@ -229,11 +286,24 @@ function attachPlan(assetId, body, userId) {
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
const startDate = body.start_date || todayStr();
const nextDue = body.next_due_date || startDate;
const lifespanAdjust = body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : (plan && plan.lifespan_adjust ? 1 : 0);
const result = run(
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, lifespan_adjust, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`,
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, lifespanAdjust, ts, ts]
);
// Copy the plan's meter definitions onto the schedule; the first threshold is one interval out.
const meters = Array.isArray(body.meters) ? body.meters : (body.plan_id ? planMeters(body.plan_id) : []);
meters.forEach((meter, index) => {
const label = (meter.label || '').trim();
if (!label) return;
const interval = Number(meter.usage_interval) || 0;
run(
`INSERT INTO asset_pm_meters (asset_pm_id, sort_order, label, unit, usage_interval, baseline_reading, due_at_reading)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
[result.lastInsertRowid, index, label, (meter.unit || '').trim() || null, interval, interval > 0 ? interval : null]
);
});
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
}
@@ -243,7 +313,7 @@ function updateAssetPm(assetId, apId, body, userId) {
if (!before) return null;
run(
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, lifespan_adjust = ?, updated_at = ? WHERE id = ?`,
[
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
@@ -253,6 +323,7 @@ function updateAssetPm(assetId, apId, body, userId) {
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
body.active === undefined ? before.active : (body.active ? 1 : 0),
body.notes ?? before.notes,
body.lifespan_adjust !== undefined ? (body.lifespan_adjust ? 1 : 0) : before.lifespan_adjust,
now(), apId
]
);
@@ -272,6 +343,44 @@ function clampRating(value) {
return Math.max(1, Math.min(5, Number(value)));
}
// Composite next-due for a schedule given the date it was last serviced. Loads the schedule's meters
// (with their reading history) and the owning asset's life, runs the earliest-wins engine, and applies
// the asset's maintenance end-date cutoff. Shared by completion and standalone reading logs.
function scheduleNextDue(ap, lastDate) {
const asset = one('SELECT in_service_date, useful_life_months FROM assets WHERE id = ?', [ap.asset_id]) || {};
const projection = computeNextDue(
{ lastDate, frequency_value: ap.frequency_value, frequency_unit: ap.frequency_unit, lifespan_adjust: Boolean(ap.lifespan_adjust) },
asset,
assetPmMeters(ap.id),
engineSettings(),
todayStr()
);
let nextDue = projection.next_due_date;
let active = ap.active;
if (ap.end_date && nextDue && nextDue > ap.end_date) {
nextDue = null;
active = 0; // schedule complete — past the asset's maintenance end date
}
return { nextDue, active };
}
// Record a meter reading against a schedule's meter (resets its baseline/threshold when this is a
// service completion). Insert into the log so the usage-rate projection stays current.
function recordMeterReading(meter, reading, readingDate, source, completionId, userId) {
const value = Number(reading);
if (!Number.isFinite(value)) return;
if (source === 'completion') {
// Completing service resets the cycle: the next threshold is one interval past this reading.
const dueAt = Number(meter.usage_interval) > 0 ? value + Number(meter.usage_interval) : null;
run('UPDATE asset_pm_meters SET baseline_reading = ?, due_at_reading = ?, last_reading = ?, last_reading_date = ? WHERE id = ?',
[value, dueAt, value, readingDate, meter.id]);
} else {
run('UPDATE asset_pm_meters SET last_reading = ?, last_reading_date = ? WHERE id = ?', [value, readingDate, meter.id]);
}
run('INSERT INTO pm_meter_readings (asset_pm_meter_id, reading, reading_date, source, completion_id, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[meter.id, value, readingDate, source, completionId || null, userId || null, now()]);
}
function completePm(assetId, apId, body, userId) {
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!ap) return null;
@@ -279,18 +388,13 @@ function completePm(assetId, apId, body, userId) {
const ts = now();
const completedDate = (body.completed_at || ts).slice(0, 10);
let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit);
let active = ap.active;
if (ap.end_date && nextDue > ap.end_date) {
nextDue = null;
active = 0; // schedule complete — past the asset's maintenance end date
}
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
const components = Array.isArray(body.components) ? body.components : planComps;
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
const ratings = body.ratings || {};
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
const meterReadings = Array.isArray(body.meter_readings) ? body.meter_readings : [];
return tx(() => {
const inserted = run(
@@ -299,7 +403,7 @@ function completePm(assetId, apId, body, userId) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), null, ts
]
);
for (const photo of photos) {
@@ -307,12 +411,44 @@ function completePm(assetId, apId, body, userId) {
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
]);
}
// Reset each meter's cycle from the reading captured during service.
for (const entry of meterReadings) {
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [entry.asset_pm_meter_id, apId]);
if (meter && entry.reading != null && entry.reading !== '') {
recordMeterReading(meter, entry.reading, completedDate, 'completion', inserted.lastInsertRowid, userId);
}
}
// Earliest-wins next-due now reflects the reset meters and the lifespan curve.
const { nextDue, active } = scheduleNextDue(ap, completedDate);
run('UPDATE pm_completions SET next_due_date = ? WHERE id = ?', [nextDue, inserted.lastInsertRowid]);
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
});
}
// Standalone usage reading (quick-log / inspection) — updates the meter's current reading and
// recomputes the schedule's next-due so a faster-than-expected usage rate pulls service in sooner.
function logReading(assetId, apId, body, userId) {
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
if (!ap) return null;
const meter = one('SELECT * FROM asset_pm_meters WHERE id = ? AND asset_pm_id = ?', [body.asset_pm_meter_id, apId]);
if (!meter) throw Object.assign(new Error('Meter not found on this schedule'), { status: 404 });
if (body.reading == null || body.reading === '' || !Number.isFinite(Number(body.reading))) {
throw Object.assign(new Error('A numeric reading is required'), { status: 400 });
}
const readingDate = (body.reading_date || now()).slice(0, 10);
const source = body.source === 'inspection' ? 'inspection' : 'manual';
return tx(() => {
recordMeterReading(meter, body.reading, readingDate, source, null, userId);
const lastDate = ap.last_completed_date || ap.start_date || readingDate;
const { nextDue, active } = scheduleNextDue(ap, lastDate);
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), apId]);
audit(userId, 'reading', 'asset_pm', apId, null, { meter: meter.label, reading: Number(body.reading), next_due_date: nextDue });
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
});
}
function listCompletions(query = {}) {
return all(
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
@@ -369,7 +505,16 @@ function getSettings() {
return {
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
pm_lead_days: Number(map.pm_lead_days || 7)
pm_lead_days: Number(map.pm_lead_days || 7),
pm_usage_tracking_enabled: map.pm_usage_tracking_enabled === '1',
pm_lifespan_curve_enabled: map.pm_lifespan_curve_enabled === '1',
pm_lifespan_min_factor: map.pm_lifespan_min_factor != null ? Number(map.pm_lifespan_min_factor) : 0.5,
pm_lifespan_curve_exponent: map.pm_lifespan_curve_exponent != null ? Number(map.pm_lifespan_curve_exponent) : 2,
// Nightly recompute job: refreshes projected due-dates so dynamic schedules don't drift between
// completions/readings. pm_last_recompute_at is job-managed (read-only to clients).
pm_recompute_enabled: map.pm_recompute_enabled === '1',
pm_recompute_hour: map.pm_recompute_hour != null ? Number(map.pm_recompute_hour) : 2,
pm_last_recompute_at: map.pm_last_recompute_at || null
};
}
@@ -378,10 +523,27 @@ function saveSettings(body, userId) {
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
if (body.pm_usage_tracking_enabled !== undefined) set('pm_usage_tracking_enabled', body.pm_usage_tracking_enabled ? 1 : 0);
if (body.pm_lifespan_curve_enabled !== undefined) set('pm_lifespan_curve_enabled', body.pm_lifespan_curve_enabled ? 1 : 0);
// Floor multiplier is clamped to a sane 0.11.0; exponent to 0.56.
if (body.pm_lifespan_min_factor !== undefined) set('pm_lifespan_min_factor', Math.min(1, Math.max(0.1, Number(body.pm_lifespan_min_factor) || 0.5)));
if (body.pm_lifespan_curve_exponent !== undefined) set('pm_lifespan_curve_exponent', Math.min(6, Math.max(0.5, Number(body.pm_lifespan_curve_exponent) || 2)));
if (body.pm_recompute_enabled !== undefined) set('pm_recompute_enabled', body.pm_recompute_enabled ? 1 : 0);
if (body.pm_recompute_hour !== undefined) set('pm_recompute_hour', Math.min(23, Math.max(0, Math.round(Number(body.pm_recompute_hour)) || 0)));
audit(userId, 'update', 'pm_settings', null, null, body);
return getSettings();
}
// Map persisted settings into the shape pmScheduling.computeNextDue expects.
function engineSettings(settings = getSettings()) {
return {
usage_enabled: settings.pm_usage_tracking_enabled,
curve_enabled: settings.pm_lifespan_curve_enabled,
min_factor: settings.pm_lifespan_min_factor,
curve_exponent: settings.pm_lifespan_curve_exponent
};
}
// Alert candidates for PM schedules that are due/overdue.
function pmAlertCandidates() {
const lead = getSettings().pm_lead_days;
@@ -398,15 +560,54 @@ function pmAlertCandidates() {
for (const ap of rows) {
const name = ap.plan_name || 'Preventative maintenance';
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
if (ap.next_due_date < today) {
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date });
// Meters at/over their threshold are reported in the alert text (e.g. "Operating Hours 512/500").
const crossed = assetPmMeters(ap.id)
.filter((m) => m.last_reading != null && m.due_at_reading != null && Number(m.last_reading) >= Number(m.due_at_reading))
.map((m) => `${m.label} ${Math.round(m.last_reading)}/${Math.round(m.due_at_reading)}${m.unit ? ' ' + m.unit : ''}`);
const usageNote = crossed.length ? ` Usage threshold reached: ${crossed.join('; ')}.` : '';
if (ap.next_due_date < today || crossed.length) {
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
} else if (ap.next_due_date <= horizon) {
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date });
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.${usageNote}`, due_date: ap.next_due_date });
}
}
return list;
}
// ---- Nightly recompute -----------------------------------------------------
// Re-derive next_due_date for every active schedule. Usage projections shift as time passes (a meter
// that has crossed its threshold becomes due "today"), so without a periodic sweep a schedule that
// isn't completed or read keeps a stale projected date. Cheap: one engine pass per active schedule.
function recomputeAllSchedules(userId) {
const rows = all('SELECT * FROM asset_pm_plans WHERE active = 1');
let updated = 0;
for (const ap of rows) {
const lastDate = ap.last_completed_date || ap.start_date || todayStr();
const { nextDue, active } = scheduleNextDue(ap, lastDate);
if (nextDue !== ap.next_due_date || (active ? 1 : 0) !== (ap.active ? 1 : 0)) {
run('UPDATE asset_pm_plans SET next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [nextDue, active, now(), ap.id]);
updated += 1;
}
}
const ranAt = now();
run(
"INSERT INTO app_settings (key, value, updated_at) VALUES ('pm_last_recompute_at', ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
[ranAt, ranAt]
);
if (userId) audit(userId, 'recompute', 'asset_pm', null, null, { scanned: rows.length, updated });
return { scanned: rows.length, updated, ran_at: ranAt };
}
// Self-gating entry point for the scheduler: runs once per day at the configured hour when enabled.
function runScheduledRecompute() {
const s = getSettings();
if (!s.pm_recompute_enabled) return { skipped: 'disabled' };
if (new Date().getHours() !== s.pm_recompute_hour) return { skipped: 'off-hour' };
if (s.pm_last_recompute_at && String(s.pm_last_recompute_at).slice(0, 10) === todayStr()) return { skipped: 'already-ran-today' };
return recomputeAllSchedules(null);
}
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
function pmBookLedger(year) {
const rows = all(
@@ -460,7 +661,10 @@ module.exports = {
listCompletions,
getSettings,
listPlans,
logReading,
pmAlertCandidates,
recomputeAllSchedules,
runScheduledRecompute,
saveSettings,
updateAssetPm,
updatePlan

View File

@@ -0,0 +1,137 @@
// Pure (DB-free) scheduling math for dynamic PM. The PM service feeds it plain objects so it can be
// unit-tested in isolation. The core idea: a PM's next-due is the EARLIEST of every enabled signal —
// the calendar interval (optionally compressed by an age/wear curve) and a projected usage threshold.
const DAY_MS = 86400000;
function toDate(dateStr) {
return new Date(`${String(dateStr).slice(0, 10)}T00:00:00`);
}
function dayString(date) {
return date.toISOString().slice(0, 10);
}
function addDays(dateStr, days) {
const d = toDate(dateStr);
d.setDate(d.getDate() + Math.round(days));
return dayString(d);
}
function daysBetween(aStr, bStr) {
return (toDate(bStr).getTime() - toDate(aStr).getTime()) / DAY_MS;
}
// Convert a calendar frequency to an approximate number of days (months ≈ 30, year = 365) so the
// lifespan factor can scale it smoothly. Mirrors the unit cases in pm.addInterval.
function intervalToDays(value, unit) {
const v = Math.max(1, Number(value) || 1);
switch (unit) {
case 'days': return v;
case 'weeks': return v * 7;
case 'quarters': return v * 91;
case 'semiannual': return v * 182;
case 'years': return v * 365;
case 'months':
default: return v * 30;
}
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
// Wear curve: returns an interval multiplier in [minFactor, 1]. New equipment (ageFraction 0) keeps the
// full interval (1.0); at/after end of estimated life (ageFraction ≥ 1) it shrinks to minFactor. The
// exponent shapes the knee — exponent 1 is linear, higher exponents hold the base cadence longer then
// drop sharply near end of life.
function lifespanFactor(ageFraction, { minFactor = 0.5, exponent = 2 } = {}) {
const frac = clamp(Number(ageFraction) || 0, 0, 1);
const floor = clamp(Number(minFactor) || 0, 0.05, 1);
const exp = Math.max(0.1, Number(exponent) || 1);
return 1 - (1 - floor) * Math.pow(frac, exp);
}
// Age fraction of an asset's estimated life at a given date (0 = brand new, 1 = at end of life).
function ageFraction(inServiceDate, usefulLifeMonths, asOfStr) {
const months = Number(usefulLifeMonths);
if (!inServiceDate || !months || months <= 0) return 0;
const elapsedDays = Math.max(0, daysBetween(inServiceDate, asOfStr));
const lifeDays = months * 30;
return clamp(elapsedDays / lifeDays, 0, 1);
}
// Usage per day inferred from a meter's readings (chronological [{ reading, reading_date }]). Uses the
// span between the earliest and latest reading; needs ≥2 readings over a positive number of days.
function usageRatePerDay(readings) {
if (!Array.isArray(readings) || readings.length < 2) return null;
const sorted = [...readings].sort((a, b) => toDate(a.reading_date) - toDate(b.reading_date));
const first = sorted[0];
const last = sorted[sorted.length - 1];
const days = daysBetween(first.reading_date, last.reading_date);
const delta = Number(last.reading) - Number(first.reading);
if (days <= 0 || delta <= 0) return null;
return delta / days;
}
// Projected date a meter reaches its threshold (due_at_reading). Already crossed → today; otherwise
// today + remaining/ratePerDay. Returns null when there is no threshold or no usable rate.
function projectUsageDue(meter, readings, todayStr) {
if (!meter || meter.due_at_reading == null) return null;
const current = meter.last_reading == null ? meter.baseline_reading : meter.last_reading;
if (current != null && Number(current) >= Number(meter.due_at_reading)) return todayStr;
const rate = usageRatePerDay(readings);
if (!rate || rate <= 0) return null;
const remaining = Number(meter.due_at_reading) - Number(current || 0);
return addDays(todayStr, Math.ceil(remaining / rate));
}
function earliest(dates) {
const valid = dates.filter(Boolean).sort();
return valid.length ? valid[0] : null;
}
// Compose the next-due date from every enabled signal and return the winner plus the per-signal
// breakdown (so the UI can explain WHY a date was chosen).
// schedule: { lastDate, frequency_value, frequency_unit, lifespan_adjust }
// asset: { in_service_date, useful_life_months }
// meters: [{ ...meter, readings: [...] }]
// settings: { usage_enabled, curve_enabled, min_factor, curve_exponent }
function computeNextDue(schedule, asset, meters, settings, today) {
const lastDate = schedule.lastDate || today;
const baseDays = intervalToDays(schedule.frequency_value, schedule.frequency_unit);
let factor = 1;
if (settings.curve_enabled && schedule.lifespan_adjust) {
const frac = ageFraction(asset.in_service_date, asset.useful_life_months, lastDate);
factor = lifespanFactor(frac, { minFactor: settings.min_factor, exponent: settings.curve_exponent });
}
const calendarDue = addDays(lastDate, baseDays * factor);
let usageDue = null;
if (settings.usage_enabled && Array.isArray(meters)) {
const projections = meters
.filter((m) => Number(m.usage_interval) > 0)
.map((m) => projectUsageDue(m, m.readings || [], today));
usageDue = earliest(projections);
}
const lifespanDue = factor < 1 ? calendarDue : null; // surfaced separately for transparency
const nextDue = earliest([calendarDue, usageDue]);
return {
next_due_date: nextDue,
signals: { calendar: calendarDue, usage: usageDue, lifespan: lifespanDue, factor },
driver: nextDue === usageDue && usageDue ? 'usage' : (factor < 1 ? 'lifespan' : 'calendar')
};
}
module.exports = {
addDays,
ageFraction,
computeNextDue,
daysBetween,
intervalToDays,
lifespanFactor,
projectUsageDue,
usageRatePerDay
};

View File

@@ -0,0 +1,118 @@
/*
* Tests for the pure dynamic-PM scheduling math: the lifespan wear curve, usage-rate inference,
* usage-threshold projection, and the earliest-wins composite next-due. No DB — plain objects in,
* dates out.
*/
const {
ageFraction, computeNextDue, intervalToDays, lifespanFactor, projectUsageDue, usageRatePerDay
} = require('./pmScheduling');
describe('pm scheduling — lifespan curve', () => {
test('factor is 1 when new and minFactor at end of life', () => {
expect(lifespanFactor(0, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(1);
expect(lifespanFactor(1, { minFactor: 0.5, exponent: 2 })).toBeCloseTo(0.5);
});
test('factor decreases monotonically with age', () => {
const early = lifespanFactor(0.25, { minFactor: 0.5, exponent: 2 });
const mid = lifespanFactor(0.5, { minFactor: 0.5, exponent: 2 });
const late = lifespanFactor(0.9, { minFactor: 0.5, exponent: 2 });
expect(early).toBeGreaterThan(mid);
expect(mid).toBeGreaterThan(late);
});
test('ageFraction clamps to [0,1] and handles missing life', () => {
expect(ageFraction(null, 60, '2026-01-01')).toBe(0);
expect(ageFraction('2020-01-01', 0, '2026-01-01')).toBe(0);
// 12 years old against a 5-year (60mo) life → clamped to 1.
expect(ageFraction('2014-01-01', 60, '2026-01-01')).toBe(1);
});
});
describe('pm scheduling — usage', () => {
test('intervalToDays converts units', () => {
expect(intervalToDays(1, 'weeks')).toBe(7);
expect(intervalToDays(2, 'months')).toBe(60);
expect(intervalToDays(1, 'years')).toBe(365);
});
test('usageRatePerDay needs ≥2 increasing readings over time', () => {
expect(usageRatePerDay([{ reading: 100, reading_date: '2026-01-01' }])).toBeNull();
const rate = usageRatePerDay([
{ reading: 100, reading_date: '2026-01-01' },
{ reading: 200, reading_date: '2026-01-11' }
]);
expect(rate).toBeCloseTo(10); // 100 units / 10 days
});
test('projectUsageDue returns today when already past threshold', () => {
const meter = { due_at_reading: 500, last_reading: 520, baseline_reading: 0 };
expect(projectUsageDue(meter, [], '2026-06-06')).toBe('2026-06-06');
});
test('projectUsageDue projects forward from the measured rate', () => {
const meter = { due_at_reading: 500, last_reading: 400, baseline_reading: 0 };
const readings = [
{ reading: 300, reading_date: '2026-01-01' },
{ reading: 400, reading_date: '2026-01-11' } // 10/day → 100 remaining ≈ 10 days
];
expect(projectUsageDue(meter, readings, '2026-06-06')).toBe('2026-06-16');
});
});
describe('pm scheduling — composite next-due (earliest wins)', () => {
const asset = { in_service_date: '2026-01-01', useful_life_months: 60 };
test('falls back to the calendar date when signals are disabled', () => {
const out = computeNextDue(
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
asset, [], { usage_enabled: false, curve_enabled: false }, '2026-06-06'
);
// 6 months ≈ 180 days out, no compression.
expect(out.next_due_date).toBe('2026-12-03');
expect(out.driver).toBe('calendar');
});
test('a fast usage rate pulls the due date in before the calendar date', () => {
const meters = [{
usage_interval: 500, due_at_reading: 500, last_reading: 450, baseline_reading: 0,
readings: [
{ reading: 0, reading_date: '2026-05-01' },
{ reading: 450, reading_date: '2026-06-05' } // ~12.8/day → ~4 days to 500
]
}];
const out = computeNextDue(
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false },
asset, meters, { usage_enabled: true, curve_enabled: false }, '2026-06-06'
);
expect(out.driver).toBe('usage');
expect(out.next_due_date < '2026-12-03').toBe(true);
});
test('the lifespan curve compresses the interval for an aged asset', () => {
const old = { in_service_date: '2021-06-06', useful_life_months: 60 }; // ~5yr old → end of life
const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 };
const fresh = computeNextDue(
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
asset, [], settings, '2026-06-06'
);
const aged = computeNextDue(
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: true },
old, [], settings, '2026-06-06'
);
expect(aged.next_due_date < fresh.next_due_date).toBe(true);
expect(aged.driver).toBe('lifespan');
});
test('lifespan compression is ignored when the schedule opts out', () => {
const old = { in_service_date: '2021-06-06', useful_life_months: 60 };
const settings = { usage_enabled: false, curve_enabled: true, min_factor: 0.5, curve_exponent: 1 };
const out = computeNextDue(
{ lastDate: '2026-06-06', frequency_value: 6, frequency_unit: 'months', lifespan_adjust: false },
old, [], settings, '2026-06-06'
);
expect(out.driver).toBe('calendar');
expect(out.signals.factor).toBe(1);
});
});

View File

@@ -3,7 +3,7 @@ const { publicUser } = require('../middleware/auth');
const { capabilitiesForRole } = require('./roles');
const defaultPreferences = {
theme: 'mixedAssetsDark'
theme: 'depreCoreDark'
};
function getPreferences(userId) {

View File

@@ -14,6 +14,50 @@ function yearOf(value) {
return new Date(`${value}T00:00:00`).getFullYear();
}
function monthOf(value) {
if (!value) return null;
return new Date(`${value}T00:00:00`).getMonth() + 1;
}
// A date falls in the period when the year matches and (no month filter, or the month matches).
function inPeriod(date, year, month) {
if (yearOf(date) !== year) return false;
if (month && monthOf(date) !== month) return false;
return true;
}
const REVALUATION_TYPES = ['revaluation', 'write_up', 'write_down'];
// Adjustment rows joined to their asset, filtered by period and (optionally) a set of types.
function adjustmentRows(year, month, types, entityId) {
return all(
`SELECT adj.*, a.asset_id, a.description, a.category, e.name AS entity_name, u.name AS created_by_name
FROM asset_adjustments adj
JOIN assets a ON a.id = adj.asset_id
LEFT JOIN entities e ON e.id = a.entity_id
LEFT JOIN users u ON u.id = adj.created_by
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY adj.adjustment_date`,
[entityId || null, entityId || null]
).filter((row) => inPeriod(row.adjustment_date, year, month) && (!types || types.includes(row.type)));
}
// Generic aggregation: group items by keyFn, summing the given numeric fields and counting rows.
function groupSummary(items, keyFn, fields) {
const groups = {};
for (const item of items) {
const key = keyFn(item) || 'Uncategorized';
const group = groups[key] || (groups[key] = { key, count: 0 });
group.count += 1;
for (const field of fields) group[field] = (group[field] || 0) + Number(item[field] || 0);
}
return Object.values(groups).map((group) => {
const out = { key: group.key, count: group.count };
for (const field of fields) out[field] = money(group[field]);
return out;
});
}
function sumBy(rows, keys) {
const totals = {};
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
@@ -210,13 +254,14 @@ function lifetimeDepreciation(options) {
function acquisitions(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = all(
`SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost
FROM assets a WHERE a.acquired_date IS NOT NULL
AND (? IS NULL OR a.entity_id = ?)
ORDER BY a.acquired_date`,
[options.entity_id || null, options.entity_id || null]
).filter((row) => yearOf(row.acquired_date) === year)
).filter((row) => inPeriod(row.acquired_date, year, month))
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
return {
columns: [
@@ -241,7 +286,10 @@ function disposalRows(year, entityId) {
function disposals(options) {
const year = Number(options.year) || currentYear();
const rows = disposalRows(year, options.entity_id).map((row) => ({
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = disposalRows(year, options.entity_id)
.filter((row) => !month || monthOf(row.disposal_date) === month)
.map((row) => ({
asset_id: row.asset_id,
description: row.description,
method: row.method,
@@ -351,15 +399,17 @@ function projection(options) {
function journalDepreciation(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const factor = options.monthlyFactor || 1;
const rules = ruleSetForBook(book);
const accounts = {};
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
const c = computeYear(asset, bk, rules, year);
if (!c.depreciation) continue;
const amount = money(c.depreciation * factor);
if (!amount) continue;
const expense = asset.gl_expense_account || '6400';
const accum = asset.gl_accumulated_account || '1590';
accounts[expense] = (accounts[expense] || 0) + c.depreciation;
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation;
accounts[expense] = (accounts[expense] || 0) + amount;
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + amount;
}
const rows = [];
for (const [key, amount] of Object.entries(accounts)) {
@@ -379,8 +429,10 @@ function journalDepreciation(options) {
function journalDisposals(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = [];
for (const d of disposalRows(year, options.entity_id)) {
if (month && monthOf(d.disposal_date) !== month) continue;
const proceeds = money(d.disposal_price - d.disposal_expense);
rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 });
rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
@@ -721,6 +773,314 @@ function alertsSince(options) {
};
}
// ---- Revaluation & write-off (transactions, journals, summaries) -----------
function revaluationTransactions(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id).map((r) => ({
adjustment_date: r.adjustment_date, asset_id: r.asset_id, description: r.description,
type: String(r.type).replace(/_/g, ' '), book_type: r.book_type, amount: money(r.amount), reason: r.reason
}));
return {
columns: [
col('adjustment_date', 'Date', 'date'), col('asset_id', 'Asset ID'), col('description', 'Description'),
col('type', 'Type'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
],
rows,
totals: sumBy(rows, ['amount'])
};
}
// Write-offs are impairment adjustments plus abandonment disposals (asset removed at no value).
function writeoffItems(year, month, entityId) {
const impairments = adjustmentRows(year, month, ['impairment'], entityId).map((r) => ({
date: r.adjustment_date, asset_id: r.asset_id, description: r.description, category: r.category,
source: 'Impairment', book_type: r.book_type, amount: money(r.amount), reason: r.reason
}));
const abandonments = disposalRows(year, entityId)
.filter((d) => d.method === 'abandonment' && (!month || monthOf(d.disposal_date) === month))
.map((d) => ({
date: d.disposal_date, asset_id: d.asset_id, description: d.description, category: d.category,
source: 'Abandonment', book_type: d.book_type, amount: money(d.net_book_value), reason: d.notes
}));
return [...impairments, ...abandonments].sort((a, b) => String(a.date).localeCompare(String(b.date)));
}
function writeoffTransactions(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = writeoffItems(year, month, options.entity_id);
return {
columns: [
col('date', 'Date', 'date'), col('asset_id', 'Asset ID'), col('description', 'Description'),
col('source', 'Source'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
],
rows: rows.map(({ category, ...rest }) => rest),
totals: sumBy(rows, ['amount'])
};
}
function journalRevaluation(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = [];
for (const r of adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id)) {
const amount = money(r.amount);
if (!amount) continue;
// write_up / revaluation increase carrying value (Dr asset, Cr revaluation surplus); write_down reverses.
const up = r.type !== 'write_down';
rows.push({ asset_id: r.asset_id, memo: 'Asset carrying value', debit: up ? amount : 0, credit: up ? 0 : amount });
rows.push({ asset_id: r.asset_id, memo: 'Revaluation surplus / reserve', debit: up ? 0 : amount, credit: up ? amount : 0 });
}
return {
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
rows,
totals: sumBy(rows, ['debit', 'credit'])
};
}
function journalWriteoff(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const rows = [];
for (const item of writeoffItems(year, month, options.entity_id)) {
const amount = money(item.amount);
if (!amount) continue;
rows.push({ asset_id: item.asset_id, memo: `${item.source} loss / expense`, debit: amount, credit: 0 });
rows.push({ asset_id: item.asset_id, memo: 'Asset / accumulated depreciation', debit: 0, credit: amount });
}
return {
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
rows,
totals: sumBy(rows, ['debit', 'credit'])
};
}
// ---- Summaries -------------------------------------------------------------
function bookSummaryItems(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
return assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk }) => {
const c = computeYear(asset, bk, rules, year);
return {
category: asset.category || 'Uncategorized',
cost: c.cost, depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
}
function assetSummary(options) {
const rows = groupSummary(bookSummaryItems(options), (i) => i.category, ['cost', 'accumulated', 'nbv'])
.map((g) => ({ category: g.key || 'Uncategorized', count: g.count, cost: g.cost, accumulated: g.accumulated, nbv: g.nbv }));
return {
columns: [
col('category', 'Category'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'),
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'accumulated', 'nbv']) }
};
}
function depreciationSummary(options) {
const rows = groupSummary(bookSummaryItems(options), (i) => i.category, ['cost', 'depreciation', 'accumulated', 'nbv'])
.map((g) => ({ category: g.key, count: g.count, cost: g.cost, depreciation: g.depreciation, accumulated: g.accumulated, nbv: g.nbv }));
return {
columns: [
col('category', 'Category'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'),
col('depreciation', 'Depreciation', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']) }
};
}
function ytdDepreciationSummary(options) {
const month = Math.min(12, Math.max(1, Number(options.month) || 12));
const items = bookSummaryItems(options).map((i) => ({ ...i, ytd: money((i.depreciation * month) / 12) }));
const rows = groupSummary(items, (i) => i.category, ['depreciation', 'ytd', 'accumulated', 'nbv'])
.map((g) => ({ category: g.key, count: g.count, depreciation: g.depreciation, ytd: g.ytd, accumulated: g.accumulated, nbv: g.nbv }));
return {
columns: [
col('category', 'Category'), col('count', 'Assets', 'number'), col('depreciation', 'Annual', 'currency'),
col('ytd', `YTD through M${month}`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv']) }
};
}
function purchaseSummary(options) {
const items = acquisitions(options).rows.map((r) => ({ category: r.category || 'Uncategorized', cost: r.acquisition_cost }));
const rows = groupSummary(items, (i) => i.category, ['cost']).map((g) => ({ category: g.key, count: g.count, cost: g.cost }));
return {
columns: [col('category', 'Category'), col('count', 'Purchases', 'number'), col('cost', 'Cost', 'currency')],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost']) }
};
}
function disposalSummary(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const items = disposalRows(year, options.entity_id)
.filter((d) => !month || monthOf(d.disposal_date) === month)
.map((d) => ({
method: String(d.method).replace(/_/g, ' '),
proceeds: money(d.disposal_price - d.disposal_expense), nbv: money(d.net_book_value), gain_loss: money(d.gain_loss)
}));
const rows = groupSummary(items, (i) => i.method, ['proceeds', 'nbv', 'gain_loss'])
.map((g) => ({ method: g.key, count: g.count, proceeds: g.proceeds, nbv: g.nbv, gain_loss: g.gain_loss }));
return {
columns: [
col('method', 'Method'), col('count', 'Disposals', 'number'), col('proceeds', 'Net proceeds', 'currency'),
col('nbv', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['proceeds', 'nbv', 'gain_loss']) }
};
}
function revaluationSummary(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const items = adjustmentRows(year, month, REVALUATION_TYPES, options.entity_id)
.map((r) => ({ type: String(r.type).replace(/_/g, ' '), amount: money(r.amount) }));
const rows = groupSummary(items, (i) => i.type, ['amount']).map((g) => ({ type: g.key, count: g.count, amount: g.amount }));
return {
columns: [col('type', 'Type'), col('count', 'Count', 'number'), col('amount', 'Net amount', 'currency')],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['amount']) }
};
}
function writeoffSummary(options) {
const year = Number(options.year) || currentYear();
const month = options.txn_month ? Number(options.txn_month) : null;
const items = writeoffItems(year, month, options.entity_id);
const rows = groupSummary(items, (i) => i.category, ['amount']).map((g) => ({ category: g.key, count: g.count, amount: g.amount }));
return {
columns: [col('category', 'Category'), col('count', 'Write-offs', 'number'), col('amount', 'Amount', 'currency')],
rows,
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['amount']) }
};
}
// ---- Registers, journals & history -----------------------------------------
function fixedAssetRegister(options) {
const book = options.book || 'GAAP';
const year = Number(options.year) || currentYear();
const rules = ruleSetForBook(book);
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk }) => {
const c = computeYear(asset, bk, rules, year);
return {
asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status,
acquired_date: asset.acquired_date, in_service_date: asset.in_service_date,
location: asset.location, custodian: asset.custodian,
cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end
};
});
return {
columns: [
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'), col('status', 'Status'),
col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'),
col('location', 'Location'), col('custodian', 'Custodian'),
col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
],
rows,
totals: sumBy(rows, ['cost', 'accumulated', 'nbv'])
};
}
// Comprehensive per-asset dossier: per-book financials (reused from the asset card) plus rich
// sections (maintenance, notes, warranties, adjustments, disposals) and photo thumbnails for PDF.
function assetJournal(options) {
const card = fixedAssetCard(options);
if (!card.meta || !card.meta.asset) return card;
const asset = one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]);
const id = asset.id;
const maintenance = all(
`SELECT c.completed_at, p.name AS plan_name, u.name AS by_name, c.cost
FROM pm_completions c JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
LEFT JOIN pm_plans p ON p.id = ap.plan_id LEFT JOIN users u ON u.id = c.completed_by
WHERE ap.asset_id = ? ORDER BY c.completed_at DESC`, [id]
).map((r) => ({ completed_at: String(r.completed_at).slice(0, 10), plan_name: r.plan_name || 'Maintenance', by_name: r.by_name, cost: money(r.cost) }));
const notes = all(
`SELECT n.created_at, n.body, u.name AS by_name FROM asset_notes n
LEFT JOIN users u ON u.id = n.created_by WHERE n.asset_id = ? ORDER BY n.created_at DESC`, [id]
).map((r) => ({ created_at: String(r.created_at).slice(0, 10), by_name: r.by_name, body: r.body }));
const warranties = all(
'SELECT provider, start_date, expiration_date, warranty_limit, coverage_details FROM warranties WHERE asset_id = ? ORDER BY expiration_date', [id]
);
const adjustments = all(
'SELECT adjustment_date, type, book_type, amount, reason FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC', [id]
).map((r) => ({ adjustment_date: r.adjustment_date, type: String(r.type).replace(/_/g, ' '), book_type: r.book_type, amount: money(r.amount), reason: r.reason }));
const disposalsList = all(
'SELECT disposal_date, method, net_book_value, gain_loss FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC', [id]
).map((r) => ({ disposal_date: r.disposal_date, method: String(r.method).replace(/_/g, ' '), net_book_value: money(r.net_book_value), gain_loss: money(r.gain_loss) }));
const sections = [
{ title: 'Maintenance history', columns: [col('completed_at', 'Completed', 'date'), col('plan_name', 'Plan'), col('by_name', 'By'), col('cost', 'Cost', 'currency')], rows: maintenance },
{ title: 'Notes', columns: [col('created_at', 'Date', 'date'), col('by_name', 'By'), col('body', 'Note')], rows: notes },
{ title: 'Warranties', columns: [col('provider', 'Provider'), col('start_date', 'Start', 'date'), col('expiration_date', 'Expires', 'date'), col('warranty_limit', 'Limit'), col('coverage_details', 'Coverage')], rows: warranties },
{ title: 'Adjustments & revaluations', columns: [col('adjustment_date', 'Date', 'date'), col('type', 'Type'), col('book_type', 'Book'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')], rows: adjustments }
];
if (disposalsList.length) {
sections.push({ title: 'Disposals', columns: [col('disposal_date', 'Date', 'date'), col('method', 'Type'), col('net_book_value', 'NBV', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')], rows: disposalsList });
}
const photoRows = all('SELECT name, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id LIMIT 12', [id]);
const photos = photoRows.map((p) => ({ caption: p.caption || p.name || 'Photo', data: p.data_base64 }));
const photoCount = one('SELECT COUNT(*) AS c FROM asset_photos WHERE asset_id = ?', [id]).c;
return { ...card, meta: { ...card.meta, layout: 'dossier', sections, photos, photo_count: photoCount } };
}
function assetHistory(options) {
const row = options.asset_id ? one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]) : null;
const asset = row ? assetFromRow(row) : null;
if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its history.' } };
const id = asset.id;
const events = [];
if (asset.acquired_date) events.push({ date: asset.acquired_date, event: 'Acquired', detail: [asset.vendor, asset.invoice_number ? `#${asset.invoice_number}` : ''].filter(Boolean).join(' '), by: '', amount: money(asset.acquisition_cost) });
if (asset.in_service_date) events.push({ date: asset.in_service_date, event: 'Placed in service', detail: asset.description, by: '', amount: '' });
for (const r of all('SELECT * FROM asset_adjustments WHERE asset_id = ?', [id])) {
events.push({ date: r.adjustment_date, event: String(r.type).replace(/_/g, ' '), detail: [r.book_type, r.reason].filter(Boolean).join(' · '), by: '', amount: money(r.amount) });
}
for (const d of all('SELECT * FROM disposals WHERE asset_id = ?', [id])) {
events.push({ date: d.disposal_date, event: `Disposal (${String(d.method).replace(/_/g, ' ')})`, detail: `Gain/(loss) ${money(d.gain_loss)}`, by: '', amount: money(d.net_book_value) });
}
for (const c of all('SELECT c.completed_at, c.cost, u.name AS by_name FROM pm_completions c JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id LEFT JOIN users u ON u.id = c.completed_by WHERE ap.asset_id = ?', [id])) {
events.push({ date: String(c.completed_at).slice(0, 10), event: 'PM completed', detail: '', by: c.by_name, amount: money(c.cost) });
}
for (const n of all('SELECT n.created_at, n.body, u.name AS by_name FROM asset_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.asset_id = ?', [id])) {
events.push({ date: String(n.created_at).slice(0, 10), event: 'Note', detail: n.body, by: n.by_name, amount: '' });
}
for (const w of all('SELECT provider, start_date FROM warranties WHERE asset_id = ?', [id])) {
events.push({ date: w.start_date || '', event: 'Warranty added', detail: w.provider || '', by: '', amount: '' });
}
for (const lg of all("SELECT al.action, al.created_at, u.name AS by_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id WHERE al.entity_type = 'asset' AND al.entity_id = ?", [String(id)])) {
if (['dispose', 'adjust', 'reverse_disposal'].includes(lg.action)) continue;
events.push({ date: String(lg.created_at).slice(0, 10), event: String(lg.action).replace(/_/g, ' '), detail: '', by: lg.by_name, amount: '' });
}
events.sort((a, b) => String(a.date).localeCompare(String(b.date)));
return {
columns: [col('date', 'Date', 'date'), col('event', 'Event'), col('detail', 'Detail'), col('by', 'By'), col('amount', 'Amount', 'currency')],
rows: events,
totals: {},
meta: { asset: { asset_id: asset.asset_id, description: asset.description, status: asset.status }, note: `${events.length} event(s) recorded.` }
};
}
// ---- Registry --------------------------------------------------------------
const REPORTS = {
@@ -750,7 +1110,34 @@ const REPORTS = {
pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) },
alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) },
fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) },
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) }
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) },
// Journals & registers
asset_journal: { title: 'Asset journal (full dossier)', group: 'Journals & registers', params: ['asset_id', 'year'], build: (o) => assetJournal(o) },
fixed_asset_journal: { title: 'Fixed asset journal (register)', group: 'Journals & registers', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => fixedAssetRegister(o) },
journal_depreciation_monthly: { title: 'Monthly depreciation journal entry', group: 'Journals & registers', params: ['book', 'year', 'month', 'entity_id'], build: (o) => journalDepreciation({ ...o, monthlyFactor: 1 / 12 }) },
journal_disposals_monthly: { title: 'Monthly disposal journal entry', group: 'Journals & registers', params: ['year', 'txn_month', 'entity_id'], build: (o) => journalDisposals(o) },
journal_revaluation: { title: 'Monthly revaluation journal entry', group: 'Journals & registers', params: ['book', 'year', 'txn_month', 'entity_id'], build: (o) => journalRevaluation(o) },
journal_writeoff: { title: 'Monthly write-off journal entry', group: 'Journals & registers', params: ['book', 'year', 'txn_month', 'entity_id'], build: (o) => journalWriteoff(o) },
// Transactions
txn_purchases: { title: 'Asset purchased (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => acquisitions(o) },
txn_disposals: { title: 'Asset disposed (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => disposals(o) },
txn_revaluations: { title: 'Asset revalued (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => revaluationTransactions(o) },
txn_writeoffs: { title: 'Asset write-off (transactions)', group: 'Transactions', params: ['year', 'txn_month', 'entity_id'], build: (o) => writeoffTransactions(o) },
// Summaries
asset_summary: { title: 'Asset summary', group: 'Summaries', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => assetSummary(o) },
depreciation_summary: { title: 'Asset depreciation summary', group: 'Summaries', params: ['book', 'year', 'entity_id', 'category', 'status'], build: (o) => depreciationSummary(o) },
ytd_depreciation_summary: { title: 'YTD asset depreciation summary', group: 'Summaries', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciationSummary(o) },
purchase_summary: { title: 'Asset purchase summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => purchaseSummary(o) },
disposal_summary: { title: 'Asset disposal summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => disposalSummary(o) },
revaluation_summary: { title: 'Asset revaluation summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => revaluationSummary(o) },
writeoff_summary: { title: 'Asset write-off summary', group: 'Summaries', params: ['year', 'txn_month', 'entity_id'], build: (o) => writeoffSummary(o) },
// Detail
asset_history: { title: 'Asset history', group: 'Detail', params: ['asset_id'], build: (o) => assetHistory(o) },
future_depreciation: { title: 'Future depreciation calculation', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) }
};
function paramSpecs() {
@@ -775,6 +1162,7 @@ function paramSpecs() {
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
year: { label: 'Year', type: 'number', default: year },
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
txn_month: { label: 'Month (optional)', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), clearable: true, default: null },
startYear: { label: 'Start year', type: 'number', default: year },
endYear: { label: 'End year', type: 'number', default: year + 5 },
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },

View File

@@ -69,7 +69,7 @@ function exportPdf(report) {
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.fontSize(16).text(report.title || 'MixedAssets report');
doc.fontSize(16).text(report.title || 'DepreCore report');
doc.fontSize(9).fillColor('#555').text(
`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` +
(report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '')
@@ -113,10 +113,111 @@ function exportPdf(report) {
});
}
// Draw a simple bordered table for a {columns, rows} block into an existing PDF document.
function drawTable(doc, columns, rows, totals) {
const left = doc.page.margins.left;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidth = width / columns.length;
const line = (cells, bold) => {
if (doc.y > doc.page.height - 60) doc.addPage();
const y = doc.y;
doc.fontSize(bold ? 8.5 : 8).fillColor('#000');
cells.forEach((cell, index) => {
const column = columns[index];
const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left';
doc.text(String(cell ?? ''), left + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true });
});
doc.moveDown(0.25);
};
line(columns.map((column) => column.label), true);
doc.moveTo(left, doc.y).lineTo(left + width, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.15);
if (!rows.length) {
doc.fontSize(8).fillColor('#777').text('None recorded.', left).moveDown(0.2);
return;
}
for (const row of rows) line(columns.map((column) => formatValue(row[column.key], column.format)));
if (totals && Object.keys(totals).length) {
doc.moveTo(left, doc.y).lineTo(left + width, doc.y).strokeColor('#ccc').stroke();
doc.moveDown(0.15);
line(columns.map((column, index) => {
if (index === 0) return 'Totals';
return Object.prototype.hasOwnProperty.call(totals, column.key) ? formatValue(totals[column.key], column.format || 'currency') : '';
}), true);
}
}
// Per-asset dossier PDF: identity, per-book financials, each meta section, then photo thumbnails.
function exportDossierPdf(report) {
const doc = new PDFDocument({ margin: 40, size: 'LETTER' });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
const meta = report.meta || {};
const asset = meta.asset || {};
doc.fontSize(17).fillColor('#000').text(`Asset journal — ${asset.asset_id || ''}`);
if (asset.description) doc.fontSize(11).fillColor('#333').text(asset.description);
doc.fontSize(8).fillColor('#777').text(`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}`);
doc.moveDown(0.5);
// Identity / financial key-values (two columns).
doc.fontSize(11).fillColor('#000').text('Asset details').moveDown(0.2);
const entries = Object.entries(asset).filter(([key]) => key !== 'asset_id' && key !== 'description');
const half = Math.ceil(entries.length / 2);
const colW = (doc.page.width - doc.page.margins.left - doc.page.margins.right) / 2;
const startY = doc.y;
entries.forEach(([key, value], index) => {
const column = index < half ? 0 : 1;
const y = startY + (index % half) * 14;
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
doc.fontSize(8).fillColor('#777').text(`${label}: `, doc.page.margins.left + column * colW, y, { continued: true });
doc.fillColor('#000').text(value === null || value === undefined || value === '' ? '—' : String(value));
});
doc.y = startY + half * 14;
doc.moveDown(0.6);
doc.fontSize(11).fillColor('#000').text('Depreciation by book').moveDown(0.2);
drawTable(doc, report.columns || [], report.rows || [], report.totals);
doc.moveDown(0.4);
for (const section of meta.sections || []) {
doc.fontSize(11).fillColor('#000').text(section.title).moveDown(0.2);
drawTable(doc, section.columns || [], section.rows || []);
doc.moveDown(0.4);
}
const photos = meta.photos || [];
if (meta.photo_count) {
doc.fontSize(11).fillColor('#000').text(`Photos (${meta.photo_count})`).moveDown(0.2);
let x = doc.page.margins.left;
let rowY = doc.y;
const thumbW = 160;
const thumbH = 120;
for (const photo of photos) {
if (x + thumbW > doc.page.width - doc.page.margins.right) { x = doc.page.margins.left; rowY += thumbH + 20; }
if (rowY + thumbH > doc.page.height - 50) { doc.addPage(); rowY = doc.y; x = doc.page.margins.left; }
try {
const base64 = String(photo.data || '').replace(/^data:[^;]+;base64,/, '');
if (base64) doc.image(Buffer.from(base64, 'base64'), x, rowY, { fit: [thumbW, thumbH] });
} catch {
doc.fontSize(7).fillColor('#999').text('[image unavailable]', x, rowY + 50, { width: thumbW, align: 'center' });
}
doc.fontSize(7).fillColor('#555').text(photo.caption || '', x, rowY + thumbH + 2, { width: thumbW, ellipsis: true });
x += thumbW + 20;
}
doc.y = rowY + thumbH + 18;
}
doc.end();
return new Promise((resolve) => {
doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) }));
});
}
async function exportReport(report, format) {
if (format === 'csv') return exportCsv(report);
if (format === 'xlsx') return exportXlsx(report);
if (format === 'pdf') return exportPdf(report);
if (format === 'pdf') return report.meta && report.meta.layout === 'dossier' ? exportDossierPdf(report) : exportPdf(report);
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
}

View File

@@ -76,9 +76,9 @@ function writeDepreciationPdf(res, year, book) {
const rows = assetExportRows();
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${book}-${year}.pdf"`);
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
doc.pipe(res);
doc.fontSize(18).text('MixedAssets Depreciation Report');
doc.fontSize(18).text('DepreCore Depreciation Report');
doc.fontSize(10).text(`Book: ${book} Year: ${year} Generated: ${new Date().toLocaleString()}`);
doc.moveDown();
doc.fontSize(9).text('Asset ID', 36, doc.y, { continued: true, width: 80 });

View File

@@ -1,9 +1,20 @@
/*
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;
// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow
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 = {
@@ -20,23 +31,35 @@ const DEFAULT_CMDB_MAP = {
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(/\/+$/, ''),
@@ -58,6 +81,7 @@ function getConfig() {
// 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,
@@ -78,58 +102,110 @@ function publicConfig() {
};
}
// 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) {
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);
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
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();
@@ -137,37 +213,47 @@ async function snFetch(c, pathAndQuery, options = {}) {
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 };
}
// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low).
// DepreCore 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' };
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()) {
ensureConfigured(c);
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 || '',
'',
`MixedAssets alert #${alert.id} (${alert.type})`,
`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);
@@ -178,21 +264,33 @@ async function createIncidentForAlert(alert, userId, c = getConfig()) {
impact,
urgency
};
if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup;
if (c.callerId) payload.caller_id = c.callerId;
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]
@@ -201,85 +299,128 @@ function alertWithCode(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) return null;
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 (!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;
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;
} catch {
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) {
if (value == null || value === '') return undefined;
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) {
if (!value) return undefined;
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 (!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 (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 (!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) asset.acquisition_cost = cost;
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)])];
@@ -295,6 +436,7 @@ async function syncCmdb(userId) {
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;
@@ -304,15 +446,21 @@ async function syncCmdb(userId) {
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;
@@ -322,9 +470,11 @@ async function syncCmdb(userId) {
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,

View File

@@ -1,11 +1,17 @@
/*
* integration service for managing the connection to a webhook endpoint and delivering alert notifications as JSON POST requests. The service includes functions for building the payload for each alert, posting the payload to the configured webhook URL with optional HMAC-SHA256 signing for authenticity verification, and delivering alerts in a best-effort manner where failures are counted but do not throw errors. Additionally, there is a function for sending a test webhook to verify that the webhook destination is configured correctly, which also audits the action for tracking purposes.
*/
const moment = require('moment');
const crypto = require('crypto');
const { audit } = require('../db');
const { getConfig } = require('./notifications');
const TIMEOUT_MS = 10000;
console.log(`${moment().format()} ✅ Webhooks service initialized with timeout of ${TIMEOUT_MS}ms`);
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
function buildPayload(alert) {
console.log(`${moment().format()} 🔔 Building webhook payload for alert ID ${alert.id} with title "${alert.title}" and type "${alert.type}"`);
return {
event: 'alert',
id: alert.id,
@@ -25,64 +31,95 @@ function buildPayload(alert) {
}
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
// so the receiver can verify authenticity via the X-MixedAssets-Signature header.
// so the receiver can verify authenticity via the X-DepreCore-Signature header.
async function postJson(url, secret, payload) {
console.log(`${moment().format()} 🚀 Posting JSON payload to webhook URL ${url} with payload: ${JSON.stringify(payload)}`);
const body = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'DepreCore-Webhook/1' };
if (secret) {
// Note: when a webhook secret is configured, we sign the raw JSON body of the request using HMAC-SHA256 with the provided secret, and we include the resulting signature in the X-DepreCore-Signature header in the format "sha256=signature"; this allows the receiver of the webhook to verify the authenticity of the request by computing the HMAC-SHA256 signature on their end using the same secret and comparing it to the signature provided in the header, which helps ensure that the webhook requests are coming from a trusted source and have not been tampered with in transit.
console.log(`${moment().format()} 🔐 Signing webhook payload with HMAC-SHA256 using provided secret`);
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
headers['X-DepreCore-Signature'] = `sha256=${signature}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
// Note: we use the Fetch API to send the POST request to the webhook URL with the JSON payload and appropriate headers, and we also set a timeout using AbortController to ensure that the request does not hang indefinitely; if the request takes longer than the configured timeout, it will be aborted and an error will be thrown, which allows us to handle cases where the webhook endpoint is unresponsive or slow to respond.
console.log(`${moment().format()} 📡 Sending POST request to ${url} with headers ${JSON.stringify(headers)} and body ${body}`);
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
return { ok: response.ok, status: response.status };
} finally {
console.log(`${moment().format()} ⏰ Clearing webhook request timeout`);
clearTimeout(timer);
}
console.log(`${moment().format()} ✅ Webhook POST request completed successfully with status ${result.status}`);
}
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
// but never throws, so one bad endpoint can't break the alert cycle.
async function deliverAlerts(alerts) {
console.log(`${moment().format()} 🚚 Delivering ${alerts.length} alerts to webhook endpoint`);
const c = getConfig();
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
// Note: if the webhook is not enabled, the URL is not configured, or there are no alerts to deliver, we skip the delivery process and log a message indicating that the webhook delivery was skipped; this allows us to avoid unnecessary attempts to send webhook requests when the configuration is incomplete or when there are no alerts to send, and it also provides visibility into why webhook deliveries may not be occurring.
console.log(`${moment().format()} 🚫 Webhook delivery skipped: no alerts or webhook not enabled`);
return { attempted: false, delivered: 0, failed: 0 };
}
let delivered = 0;
let failed = 0;
for (const alert of alerts) {
// Note: for each alert, we attempt to post the JSON payload to the configured webhook URL using the postJson function, and we count the number of successful deliveries and failures; if a delivery fails (either due to an error or a non-OK response), we increment the failure count but do not throw an error, which allows us to continue attempting to deliver subsequent alerts even if one or more deliveries fail, ensuring that a single bad endpoint or alert does not disrupt the entire alert delivery process.
console.log(`${moment().format()} 📡 Attempting to deliver alert to webhook: ${alert.title}`);
try {
// Note: we build the payload for the alert using the buildPayload function, which creates a stable and flat JSON object with relevant information about the alert, and then we post it to the webhook URL using the postJson function; if the postJson function returns an OK response, we increment the delivered count, otherwise we increment the failed count, allowing us to track the success and failure of each delivery attempt.
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
if (result.ok) delivered += 1;
else failed += 1;
} catch {
} catch (error) {
console.log(`${moment().format()} ❌ Failed to deliver alert to webhook: ${error.message}`);
failed += 1;
}
}
console.log(`${moment().format()} 🚚 Webhook delivery complete: ${delivered} delivered, ${failed} failed`);
return { attempted: true, delivered, failed };
}
// Send a test webhook with a simple payload to verify that the webhook URL is configured correctly and can receive requests. This function is intended to be called from an API endpoint where a user can trigger a test webhook, and it will also audit the action for tracking purposes. If the webhook URL is not configured or if the request fails, it throws an error with details about the failure.
async function sendTestWebhook(userId) {
console.log(`${moment().format()} 🚀 Sending test webhook to verify configuration`);
const c = getConfig();
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
if (!c.webhookUrl) {
// Note: if the webhook URL is not configured, we cannot send the test webhook, so we log an error message and throw an error indicating that the webhook URL is missing; this allows us to provide clear feedback about why the test webhook could not be sent and helps users understand that they need to configure the webhook URL before they can successfully send a test webhook.
console.log(`${moment().format()} ❌ Test webhook failed: Webhook URL is not configured`);
throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
}
const payload = {
event: 'test',
severity: 'info',
title: 'MixedAssets webhook test',
title: 'DepreCore webhook test',
message: 'If you received this, your webhook destination is configured correctly.',
sent_at: new Date().toISOString()
};
let result;
// Note: we attempt to send the test webhook using the postJson function with the test payload, and we catch any errors that occur during the request; if an error occurs, we log the error message and throw a new error indicating that the webhook request failed, along with the original error message for details; this allows us to provide clear feedback about why the test webhook failed and helps users troubleshoot any issues with their webhook configuration or endpoint.
try {
console.log(`${moment().format()} 📡 Sending test webhook to: ${c.webhookUrl}`);
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
} catch (error) {
console.log(`${moment().format()} ❌ Test webhook failed: ${error.message}`);
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
}
console.log(`${moment().format()} 📡 Test webhook sent successfully to: ${c.webhookUrl}`) ;
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
if (!result.ok) {
// Note: if the response from the webhook endpoint is not OK (i.e., not in the 200-299 range), we log an error message with the HTTP status code returned by the endpoint and throw an error indicating that the webhook endpoint returned a non-OK status; this allows us to provide clear feedback about why the test webhook failed and helps users understand that their webhook endpoint may be rejecting the request or encountering an error when processing it.
console.log(`${moment().format()} ❌ Test webhook failed: Webhook endpoint returned HTTP ${result.status}`);
throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
}
console.log(`${moment().format()} ✅ Test webhook delivered successfully with HTTP ${result.status}`);
return { ok: true, status: result.status };
}
// Export the functions for use in other modules. This allows the webhook-related logic to be organized in a single module and reused across different parts of the application, such as in API endpoints for sending test webhooks or in the alert processing logic for delivering alerts to the configured webhook endpoint.
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };

View File

@@ -1,9 +1,20 @@
/*
Workday integration service for managing the connection configuration and synchronizing worker data from Workday into the local system. This module provides functions to get and save the Workday connection settings, fetch an access token using OAuth client credentials, normalize worker data from the Workday API, and perform synchronization of workers on demand or on a scheduled basis. The synchronization process involves fetching worker data from Workday, normalizing it into a consistent format, and then upserting employee and contact records in the local database based on the Workday worker information. The module also includes error handling and auditing for tracking changes to the connection settings and synchronization actions.
*/
const moment = require('moment');
const { audit, json, now, one, run } = require('../db');
const { upsertEmployee } = require('./assignments');
const { upsertWorkdayContact } = require('./contacts');
console.log(`${moment().format()} 🔧 Workday service module loaded`);
// Convert a database row into a public-facing connection object, excluding sensitive information like the client secret and including a flag to indicate whether the connection is fully configured based on the presence of required fields; this allows us to safely expose connection information in API responses without risking credential leaks.
function publicConnection(row) {
if (!row) return null;
console.log(`${moment().format()} 🔍 Mapping database row to public connection object for row ID: ${row?.id}`);
if (!row) {
// Note: if no connection row is found in the database, we return null to indicate that there is no configured connection; this allows API endpoints to handle the absence of a connection gracefully.
console.log(`${moment().format()} ⚠️ No Workday connection found in database`);
return null;
}
return {
id: row.id,
name: row.name,
@@ -21,22 +32,35 @@ function publicConnection(row) {
};
}
// Clear any cached connection data. In this implementation, we don't have an actual cache layer, but this function can be expanded in the future to clear in-memory caches or other caching mechanisms if needed; for now, it serves as a placeholder to indicate where cache clearing logic would go.
function clearCache() {
console.log(`${moment().format()} 🧹 Clearing Workday connection cache (no-op)`);
}
// Split a full name into first and last name components. This is a simple heuristic that takes the first word as the first name and the rest as the last name, which may not be perfect but provides a reasonable default for many cases; this allows us to extract more granular name information from a single full name field when normalizing worker data from Workday.
function splitName(full) {
console.log(`${moment().format()} 🔍 Splitting full name into first and last name: "${full}"`);
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return { first: null, last: null };
if (parts.length === 1) return { first: parts[0], last: null };
if (!parts.length) return { first: null, last: null }; // Note: if the full name is empty or only contains whitespace, we return null for both first and last name to indicate that we couldn't extract any name information; this allows us to handle cases where the name field might be missing or invalid without causing errors.
if (parts.length === 1) return { first: parts[0], last: null }; // Note: if the full name only contains one word, we treat it as the first name and set the last name to null; this allows us to handle cases where only a single name is provided without assuming that it must be a last name.
console.log(`${moment().format()} ✅ Split name into first: "${parts[0]}", last: "${parts.slice(1).join(' ')}"`);
return { first: parts[0], last: parts.slice(1).join(' ') };
}
// Get the current Workday connection configuration as a public-facing object. This function retrieves the connection information from the database and maps it to a format that can be safely exposed in API responses, excluding sensitive fields and including a flag for whether the connection is fully configured; this allows API endpoints to provide connection information to clients without risking credential exposure.
function getConnection() {
console.log(`${moment().format()} 🔍 Retrieving Workday connection from database`);
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
}
// Get the raw Workday connection row from the database, including sensitive fields. This function is used internally when we need to access the full connection information, such as when saving updates or performing synchronization, and should not be exposed in API responses; this allows us to maintain a clear separation between internal data access and public-facing data structures.
function getPrivateConnection() {
console.log(`${moment().format()} 🔍 Retrieving private Workday connection from database`);
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
}
// Save the Workday connection configuration to the database, either by creating a new record or updating the existing one. This function takes the input from the API request body, merges it with any existing connection information, and saves it to the database while ensuring that required fields are present and that sensitive information like the client secret is handled appropriately; this allows API endpoints to update the connection settings while maintaining data integrity and security.
function saveConnection(body, userId) {
console.log(`${moment().format()} 🔄 Saving Workday connection with input: ${JSON.stringify(body)}`);
const timestamp = now();
const existing = getPrivateConnection();
const payload = {
@@ -53,6 +77,8 @@ function saveConnection(body, userId) {
};
if (existing) {
// Note: when updating an existing connection, we only update the fields that are provided in the input, while keeping the existing values for any fields that are not included; this allows for partial updates without requiring the client to resend all connection information, and it also ensures that we don't accidentally overwrite existing values with empty or default values if they are not included in the update request.
console.log(`${moment().format()} 🔄 Updating existing Workday connection with ID: ${existing.id}`);
run(
`UPDATE workday_connections SET
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
@@ -61,6 +87,8 @@ function saveConnection(body, userId) {
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
);
} else {
// Note: when creating a new connection, we require all necessary fields to be provided in the input, and we insert a new record into the database with the provided values; this allows us to ensure that we have a complete set of connection information when creating a new connection, and it also allows us to handle the case where there is no existing connection in the database.
console.log(`${moment().format()} Creating new Workday connection`);
run(
`INSERT INTO workday_connections (
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
@@ -71,10 +99,13 @@ function saveConnection(body, userId) {
const saved = getConnection();
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
console.log(`${moment().format()} 📤 Auditing update to Workday connection: ${saved.id}`);
return saved;
}
// Fetch an access token from Workday using the client credentials flow. This function constructs the appropriate authorization header using the client ID and client secret, makes a POST request to the token URL, and returns the access token if the request is successful; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent API requests to fetch worker data.
async function fetchAccessToken(connection) {
console.log(`${moment().format()} 🔐 Fetching Workday access token from token URL: ${connection.token_url}`);
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
const response = await fetch(connection.token_url, {
method: 'POST',
@@ -85,23 +116,35 @@ async function fetchAccessToken(connection) {
body: new URLSearchParams({ grant_type: 'client_credentials' })
});
if (!response.ok) {
// Note: if the token request fails, we throw an error with the status code to indicate that we were unable to authenticate with Workday; this allows API endpoints that call this function to handle authentication errors appropriately and provide feedback to the user.
console.log(`${moment().format()} ❌ Workday token request failed with status: ${response.status}`);
throw new Error(`Workday token request failed: ${response.status}`);
}
const body = await response.json();
if (!body.access_token) throw new Error('Workday token response did not include an access token');
if (!body.access_token) {
// Note: if the token response does not include an access token, we throw an error to indicate that the authentication was unsuccessful; this allows us to catch cases where the token request might succeed but still fail to provide the necessary token for API requests, and it also provides feedback for troubleshooting authentication issues.
console.log(`${moment().format()} ❌ Workday token response did not include an access token`);
throw new Error('Workday token response did not include an access token');
}
console.log(`${moment().format()} ✅ Successfully fetched Workday access token`);
return body.access_token;
}
// Normalize worker data from the Workday API into a consistent format for upserting into the local database. This function takes the raw worker data from Workday, which may have varying field names and structures, and maps it to a standardized format with consistent field names for things like worker ID, name, email, department, manager information, and so on; this allows us to handle different versions of the Workday API or different configurations that might return worker data in slightly different formats while still being able to process it in a consistent way for synchronization.
function normalizeWorkers(payload) {
// Note: the payload from Workday can come in various formats depending on the API version and configuration, so we check for multiple possible structures to extract the array of worker data; this allows us to be flexible in handling different responses from Workday while still being able to normalize the worker data effectively.
const rows = Array.isArray(payload)
? payload
: payload.workers || payload.Workers || payload.data || payload.value || [];
// Note: when normalizing each worker, we check for multiple possible field names for each piece of information (e.g., name, email, department) to account for variations in the Workday API response; we also use the splitName function to extract first and last names from a full name field when necessary, and we include the original worker data in the source_payload for reference; this allows us to create a consistent format for worker data that can be used for upserting into the local database while still retaining the original information from Workday for troubleshooting and auditing purposes.
console.log(`${moment().format()} 🔍 Normalizing Workday worker data with ${rows.length} workers found in payload`);
return rows.map((worker) => {
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
const fallbackName = splitName(name);
const managerName = worker.managerName || worker.manager?.descriptor;
const managerSplit = splitName(managerName);
console.log(`${moment().format()} 🔍 Normalizing worker data for: ${name}`);
return {
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
@@ -124,31 +167,47 @@ function normalizeWorkers(payload) {
});
}
// Synchronize workers from Workday by fetching the worker data using the API, normalizing it, and then upserting employee and contact records in the local database based on the Workday worker information. This function handles the entire synchronization process, including error handling and auditing, and it updates the last synchronization timestamp and status in the database after the process is complete; this allows us to keep our local employee and contact records in sync with Workday on demand or on a scheduled basis, ensuring that we have up-to-date information about our workforce for reporting and analysis.
async function syncWorkers(userId) {
console.log(`${moment().format()} 🔄 Starting Workday worker synchronization`);
const connection = getPrivateConnection();
if (!connection || !connection.enabled) throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
if (!connection || !connection.enabled) {
// Note: if there is no connection configured or if the connection is not enabled, we throw an error to indicate that synchronization cannot proceed; this allows API endpoints that call this function to provide feedback to the user about the need to configure and enable the Workday connection before attempting to synchronize workers.
console.log(`${moment().format()} ⚠️ Workday connection is not enabled or not configured`);
throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
}
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
// Note: if the connection is missing any of the required fields for making API requests (base URL, token URL, client ID, or client secret), we throw an error to indicate that the connection is not properly configured; this allows us to catch configuration issues before attempting to make API requests and provides feedback for troubleshooting the connection settings.
console.log(`${moment().format()} ⚠️ Workday connection is missing URL or OAuth credentials`);
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
}
const token = await fetchAccessToken(connection);
const url = new URL(connection.workers_path || '/workers', connection.base_url);
const token = await fetchAccessToken(connection); // Note: we fetch an access token using the client credentials flow before making API requests to Workday; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent requests to fetch worker data.
const url = new URL(connection.workers_path || '/workers', connection.base_url); // Note: we construct the URL for fetching workers by combining the base URL from the connection with the workers path, which allows for flexibility in how the API endpoint is structured; this also allows us to support different versions of the Workday API or custom configurations that might use different paths for accessing worker data.
console.log(`${moment().format()} 🔍 Fetching workers from Workday API at URL: ${url.href}`);
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
}
});
console.log(`${moment().format()} 📥 Received response from Workday API`);
if (!response.ok) {
// Note: if the API request to fetch workers fails, we throw an error with the status code to indicate that we were unable to retrieve worker data from Workday; this allows API endpoints that call this function to handle API errors appropriately and provide feedback to the user about issues with fetching data from Workday.
console.log(`${moment().format()} ❌ Workday workers request failed with status: ${response.status}`);
throw new Error(`Workday workers request failed: ${response.status}`);
}
// Note: after successfully fetching the worker data from Workday, we normalize it into a consistent format and then upsert employee and contact records in the local database for each worker; this allows us to keep our local records in sync with Workday and ensures that we have up-to-date information about our workforce for reporting and analysis.
const workers = normalizeWorkers(await response.json());
for (const worker of workers) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
// Note: when processing each worker from Workday, we log the name and Workday ID for tracking purposes, and then we upsert employee and contact records in the local database based on the worker information; this allows us to maintain accurate employee and contact records that reflect the current state of our workforce as represented in Workday, and it also provides visibility into the synchronization process for troubleshooting and auditing.
console.log(`${moment().format()} 🔄 Upserting worker into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
}
// Note: after the synchronization process is complete, we update the last synchronization timestamp and status in the database to reflect the results of the sync, and we audit the synchronization action for tracking purposes; this allows us to maintain a record of when synchronizations occurred and how many workers were imported, which can be useful for troubleshooting and reporting on the integration with Workday.
const timestamp = now();
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
timestamp,
@@ -157,31 +216,49 @@ async function syncWorkers(userId) {
connection.id
]);
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
console.log(`${moment().format()} ✅ Workday worker synchronization complete: ${workers.length} workers imported`);
return { imported: workers.length, workers };
}
// Import workers from a provided payload, which is expected to be in the same format as the normalized worker data from Workday. This function allows for manual import of worker data, such as from a file upload or an API request with worker information, and it processes the data in the same way as the synchronization function by upserting employee and contact records in the local database; this provides flexibility for importing worker data from sources other than direct API calls to Workday while still maintaining consistency in how the data is processed and stored.
function importWorkersFromPayload(workers, userId) {
const normalized = normalizeWorkers(workers);
console.log(`${moment().format()} 🔄 Importing workers from provided payload with ${workers.length} workers`);
const normalized = normalizeWorkers(workers); // Note: we normalize the provided worker data using the same normalization function as the synchronization process to ensure that the data is in a consistent format for upserting into the local database; this allows us to handle different input formats while still processing the worker data in a consistent way.
for (const worker of normalized) {
upsertEmployee(worker);
upsertWorkdayContact(worker);
// Note: when importing workers from a payload, we process the data in the same way as the synchronization function by normalizing it and then upserting employee and contact records in the local database; this allows us to maintain consistency in how worker data is handled regardless of the source, and it also provides a way to import worker data from sources other than direct API calls to Workday.
console.log(`${moment().format()} 🔄 Upserting worker from payload into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
}
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
console.log(`${moment().format()} ✅ Worker import from payload complete: ${normalized.length} workers imported`);
return { imported: normalized.length };
}
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
async function runScheduledSync() {
console.log(`${moment().format()} ⏰ Checking if scheduled Workday sync should run`);
const connection = getPrivateConnection();
if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true };
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true };
if (!connection || !connection.sync_enabled || !connection.enabled) {
// Note: if the connection is not enabled, we skip the sync to avoid unnecessary API calls and processing; this allows us to control when automatic synchronizations run and prevents errors when attempting to connect to Workday.
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Connection not enabled`);
return { skipped: true };
}
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
// Note: before running a scheduled sync, we check if the connection is fully configured with all required fields; if any of the necessary configuration is missing, we skip the sync to avoid errors when attempting to connect to Workday, and we log a message indicating that the sync was skipped due to incomplete configuration; this allows us to ensure that automatic synchronizations only run when the connection is properly set up and prevents unnecessary errors or failed sync attempts.
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Incomplete connection configuration`);
return { skipped: true };
}
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
// Note: if the last synchronization occurred within the configured interval, we skip the sync to avoid unnecessary API calls and processing; this allows us to control the frequency of automatic synchronizations and prevent excessive load on the Workday API or our local system while still ensuring that we keep our worker data reasonably up-to-date.
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Sync interval not elapsed`);
return { skipped: true };
}
return syncWorkers(null);
}
// Export the functions for use in other modules. This allows the Workday integration logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or scheduled tasks for automatic synchronization.
module.exports = {
getConnection,
importWorkersFromPayload,

View File

@@ -1,32 +1,50 @@
/*
Service for managing depreciation zones, which are used to define special rules for assets located in certain areas (e.g. opportunity zones). This includes functions for creating, updating, deleting, and retrieving zones, as well as caching for efficient lookups during depreciation calculations.
*/
const moment = require('moment');
const { all, audit, now, one, run } = require('../db');
let cache = null;
// Clear the cache, forcing a reload from the database on the next lookup. This should be called after any changes to the zones to ensure that the cache stays up to date.
function clearCache() {
console.log(`${moment().format()} 🧹 Clearing depreciation zones cache`);
cache = null;
}
// Load all zones into a cache object keyed by code for efficient lookups. This is used during depreciation calculations to quickly retrieve zone information without hitting the database repeatedly, improving performance while still ensuring that we have the necessary data available.
function lookup() {
if (!cache) {
console.log(`${moment().format()} 📦 Loading depreciation zones into cache`);
cache = {};
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
}
console.log(`${moment().format()} ✅ Depreciation zones cache loaded with ${Object.keys(cache).length} zones`);
return cache;
}
// Raw row (incl. dates) for the engine; null when unknown or disabled.
function getZone(code) {
if (!code) return null;
if (!code) {
// Note: we allow getZone to be called with a falsy code (e.g. null or undefined) to simplify logic in the depreciation engine, but we return null in that case since a valid zone code is required for a lookup; this allows the engine to call getZone without needing to check for the presence of a code first, while still ensuring that we don't attempt to look up an invalid code.
console.log(`${moment().format()} ⚠️ No zone code provided for lookup`);
return null;
}
const zone = lookup()[code];
return zone && zone.enabled ? zone : null;
}
// Helper function to create a slug from a string, used for generating zone codes. This ensures that zone codes are consistent and URL-friendly, while still allowing for human-readable names to be used for the zones.
function slugify(value) {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
}
// Convert a database row into a zone object with the expected properties for the depreciation engine, including an asset count for reporting purposes. This allows us to separate the raw database representation from the format used in the engine, while still providing all the necessary information for both use cases.
function fromRow(row) {
if (!row) return null;
if (!row) {
console.log(`${moment().format()} ⚠️ No zone row provided for conversion`);
return null;
}
return {
id: row.id,
code: row.code,
@@ -39,17 +57,24 @@ function fromRow(row) {
leasehold_life_years: row.leasehold_life_years,
section_179_increase: row.section_179_increase,
section_179_cost_factor: row.section_179_cost_factor,
section_179_threshold_increase: row.section_179_threshold_increase,
section_179_pis_start: row.section_179_pis_start,
section_179_pis_end: row.section_179_pis_end,
notes: row.notes,
enabled: Boolean(row.enabled),
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
};
}
// List all zones, converting each database row into a zone object for use in the application. This allows us to retrieve all zones in a format that includes the necessary properties for both the engine and reporting, while still keeping the database representation separate.
function list() {
console.log(`${moment().format()} 📋 Listing all depreciation zones`);
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
}
// Normalize and validate input data for creating or updating a zone, ensuring that all required fields are present and properly formatted. This helps to prevent invalid data from being entered into the database, while still allowing for flexibility in the input format (e.g. allowing empty strings to be treated as null).
function normalize(body) {
console.log(`${moment().format()} 🧹 Normalizing input data for zone: ${JSON.stringify(body)}`);
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
return {
name: String(body.name || '').trim(),
@@ -61,53 +86,91 @@ function normalize(body) {
leasehold_life_years: num(body.leasehold_life_years),
section_179_increase: Number(body.section_179_increase) || 0,
section_179_cost_factor: body.section_179_cost_factor === '' || body.section_179_cost_factor === null || body.section_179_cost_factor === undefined ? 1 : Number(body.section_179_cost_factor),
section_179_threshold_increase: Number(body.section_179_threshold_increase) || 0,
section_179_pis_start: body.section_179_pis_start || null,
section_179_pis_end: body.section_179_pis_end || null,
notes: body.notes || null,
enabled: body.enabled === false ? 0 : 1
};
}
// Create a new zone with the specified properties, ensuring that the code is unique and properly formatted. This allows us to add new zones to the system while maintaining data integrity and providing feedback on any issues with the input.
function createZone(body, userId) {
console.log(`${moment().format()} Creating new depreciation zone with input: ${JSON.stringify(body)}`);
const input = normalize(body);
if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 });
if (!input.name) {
console.log(`${moment().format()} ⚠️ Zone name is required for creation`);
throw Object.assign(new Error('A zone name is required'), { status: 400 });
}
const code = slugify(body.code || input.name);
if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
if (!code) {
// Note: we require a valid code for the zone, which can be provided directly or generated from the name; if we can't generate a valid code, we throw an error since the code is necessary for lookups and must be unique.
console.log(`${moment().format()} ⚠️ Zone code is required for creation`);
throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
}
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
// Note: we check for the existence of a zone with the same code before attempting to create a new one, and we throw an error if a duplicate is found since the code must be unique; this allows us to prevent conflicts and maintain data integrity in the database.
console.log(`${moment().format()} ⚠️ A zone with that code already exists`);
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
}
const ts = now();
// Note: we use a parameterized query here to prevent SQL injection, and we include all the relevant fields in the insert statement to ensure that the new zone is created with the correct properties; this allows us to safely add new zones to the database while maintaining data integrity.
run(
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, section_179_increase, section_179_cost_factor, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, ts, ts]
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, section_179_increase, section_179_cost_factor, section_179_threshold_increase, section_179_pis_start, section_179_pis_end, notes, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.section_179_threshold_increase, input.section_179_pis_start, input.section_179_pis_end, input.notes, input.enabled, ts, ts]
);
// Clear the cache after creating a new zone to ensure that it is included in subsequent lookups, and audit the creation action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
console.log(`${moment().format()} 🧹 Clearing cache after creating new zone: ${code}`);
clearCache();
audit(userId, 'create', 'depreciation_zone', code, null, input);
console.log(`${moment().format()} ✅ Depreciation zone created with code: ${code}`);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
// Update an existing zone with the specified properties, ensuring that the code exists and that the input is properly normalized. This allows us to modify existing zones while maintaining data integrity and providing feedback on any issues with the input.
function updateZone(code, body, userId) {
console.log(`${moment().format()} ✏️ Updating depreciation zone with code: ${code}, input: ${JSON.stringify(body)}`);
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return null;
if (!before) {
// Note: we check for the existence of the zone before attempting to update it, and we return null if it doesn't exist since there's nothing to update; this allows us to handle cases where an invalid code is provided gracefully, while still ensuring that we don't attempt to update a non-existent record.
console.log(`${moment().format()} ⚠️ Zone with code ${code} not found`);
return null;
}
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
// Note: we use a parameterized query here to prevent SQL injection, and we include all the relevant fields in the update statement to ensure that the zone is updated with the correct properties; this allows us to safely modify existing zones in the database while maintaining data integrity.
run(
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
max_recovery_years = ?, leasehold_life_years = ?, section_179_increase = ?, section_179_cost_factor = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.notes, input.enabled, now(), code]
max_recovery_years = ?, leasehold_life_years = ?, section_179_increase = ?, section_179_cost_factor = ?,
section_179_threshold_increase = ?, section_179_pis_start = ?, section_179_pis_end = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.section_179_increase, input.section_179_cost_factor, input.section_179_threshold_increase, input.section_179_pis_start, input.section_179_pis_end, input.notes, input.enabled, now(), code]
);
// Clear the cache after updating the zone to ensure that the changes are reflected in subsequent lookups, and audit the update action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
console.log(`${moment().format()} 🧹 Clearing cache after updating zone: ${code}`);
clearCache();
audit(userId, 'update', 'depreciation_zone', code, before, input);
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
}
// Delete a zone by code, ensuring that the code exists before attempting to delete it. This allows us to remove zones from the system while maintaining data integrity and providing feedback on any issues with the input.
function deleteZone(code, userId) {
console.log(`${moment().format()} 🗑️ Deleting depreciation zone with code: ${code}`);
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
if (!before) return false;
if (!before) {
// Note: we check for the existence of the zone before attempting to delete it, and we return false if it doesn't exist since there's nothing to delete; this allows us to handle cases where an invalid code is provided gracefully, while still ensuring that we don't attempt to delete a non-existent record.
console.log(`${moment().format()} ⚠️ Zone with code ${code} not found`);
return false;
}
console.log(`${moment().format()} 🗑️ Deleting depreciation zone with code: ${code}`);
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
// Clear the cache after deleting the zone to ensure that it is removed from subsequent lookups, and audit the deletion action for tracking purposes; this allows us to maintain an accurate cache and keep a record of changes to the zones for accountability.
console.log(`${moment().format()} 🧹 Clearing cache after deleting zone: ${code}`);
clearCache();
audit(userId, 'delete', 'depreciation_zone', code, before, null);
return true;
}
// Export the functions for use in other modules. This allows the zone management logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or the depreciation engine.
module.exports = {
clearCache,
createZone,