/** * Device-link reconciler — links unlinked device_external_ids rows to a * configuration_item. Cascading match strategies, highest confidence first. * Conflicts (multiple matches) are logged for admin review, not auto-merged. * * Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet — * reviewer should approve the match strategies + conflict policy first. */ import postgresClient from '@/lib/services/postgres-client'; type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual'; interface UnlinkedRow { id: number; source: string; source_id: string; hostname: string | null; serial: string | null; mac: string | null; company_id: number | null; } interface MatchCandidate { configuration_item_id: number; link_confidence: LinkConfidence; } export interface ReconcileResult { scanned: number; linked: number; conflicts: number; unmatched: number; byConfidence: Record; } const LINK_CONFIDENCE_RANK: Record = { canonical: 100, exact_uid: 90, exact_serial: 80, mac: 70, hostname_in_company: 60, manual: 50, }; // Common BIOS/inventory placeholder serials that shouldn't be matched on — // hundreds of unrelated CIs share these and any link based on them is noise. const PLACEHOLDER_SERIALS = new Set([ '', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed', 'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.', 'system serial number', 'chassis serial number', '0000000000', '00000000', 'ffffffffffff', '00000000-0000-0000-0000-000000000000', ]); function isPlaceholderSerial(serial: string): boolean { const s = serial.trim().toLowerCase(); if (s.length < 4) return true; if (PLACEHOLDER_SERIALS.has(s)) return true; // Strings that are all the same character (e.g. "00000000", "FFFFFFFF"). if (/^(.)\1+$/.test(s)) return true; return false; } async function findBySerial(serial: string): Promise { if (isPlaceholderSerial(serial)) return []; const res = await postgresClient.query<{ id: string }>( `SELECT id::text FROM configuration_items WHERE serial_number IS NOT NULL AND serial_number = $1 AND (is_deleted IS NULL OR is_deleted = false)`, [serial] ); return res.rows.map((r) => ({ configuration_item_id: Number(r.id), link_confidence: 'exact_serial' as const, })); } async function findByMac(mac: string): Promise { const res = await postgresClient.query<{ id: string }>( `SELECT id::text FROM configuration_items WHERE rmm_device_audit_mac_address IS NOT NULL AND LOWER(rmm_device_audit_mac_address) = LOWER($1) AND (is_deleted IS NULL OR is_deleted = false)`, [mac] ); return res.rows.map((r) => ({ configuration_item_id: Number(r.id), link_confidence: 'mac' as const, })); } async function findByHostnameInCompany( hostname: string, companyId: number | null ): Promise { if (!companyId) return []; const res = await postgresClient.query<{ id: string }>( `SELECT id::text FROM configuration_items WHERE company_id = $1 AND reference_title IS NOT NULL AND LOWER(reference_title) = LOWER($2) AND (is_deleted IS NULL OR is_deleted = false)`, [companyId, hostname] ); return res.rows.map((r) => ({ configuration_item_id: Number(r.id), link_confidence: 'hostname_in_company' as const, })); } async function applyLink( rowId: number, configurationItemId: number, confidence: LinkConfidence ): Promise { await postgresClient.query( `UPDATE device_external_ids SET configuration_item_id = $2, link_confidence = $3, linked_at = NOW() WHERE id = $1 AND configuration_item_id IS NULL`, [rowId, configurationItemId, confidence] ); // Propagate the new link into endpoint_audits / device_observations that // were anchored only on the tool-side ID (e.g. an IT Glue config) at the // time they were written. Without this they'd stay "unanchored" in the UI. const linked = await postgresClient.query<{ source: string; source_id: string; }>( `SELECT source, source_id FROM device_external_ids WHERE id = $1`, [rowId] ); const link = linked.rows[0]; if (!link) return; if (link.source === 'itglue') { await postgresClient.query( `UPDATE endpoint_audits SET configuration_item_id = $1 WHERE configuration_item_id IS NULL AND itglue_configuration_id::text = $2`, [configurationItemId, link.source_id] ); } } async function recordConflict( rowId: number, candidates: MatchCandidate[] ): Promise { // Order candidates highest-confidence-first so the admin UI sees the best // match at the top. const ordered = [...candidates].sort( (a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence] ); const ciIds = ordered.map((c) => c.configuration_item_id); const confidences = ordered.map((c) => c.link_confidence); await postgresClient.query( `INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences) VALUES ($1, $2::bigint[], $3::text[]) ON CONFLICT (device_external_id) WHERE resolved_at IS NULL DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids, match_confidences = EXCLUDED.match_confidences, detected_at = NOW()`, [rowId, ciIds, confidences] ); } function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null { if (candidates.length === 0) return null; const ids = new Set(candidates.map((c) => c.configuration_item_id)); if (ids.size > 1) return null; // ambiguous — admin review return candidates.reduce((best, c) => LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best ); } /** * Run one reconciliation pass over unlinked rows. Idempotent — safe to run * repeatedly. Caller should schedule via sync-scheduler. */ export async function reconcileUnlinkedDevices(opts?: { limit?: number; dryRun?: boolean; }): Promise { const limit = opts?.limit ?? 500; const dryRun = opts?.dryRun ?? false; const result: ReconcileResult = { scanned: 0, linked: 0, conflicts: 0, unmatched: 0, byConfidence: { canonical: 0, exact_uid: 0, exact_serial: 0, mac: 0, hostname_in_company: 0, manual: 0, }, }; const unlinked = await postgresClient.query( `SELECT id, source, source_id, hostname, serial, mac, company_id FROM device_external_ids WHERE configuration_item_id IS NULL ORDER BY last_seen_at DESC NULLS LAST LIMIT $1`, [limit] ); for (const row of unlinked.rows) { result.scanned += 1; const candidates: MatchCandidate[] = []; if (row.serial) candidates.push(...(await findBySerial(row.serial))); if (row.mac) candidates.push(...(await findByMac(row.mac))); if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id))); if (candidates.length === 0) { result.unmatched += 1; continue; } const ids = new Set(candidates.map((c) => c.configuration_item_id)); if (ids.size > 1) { result.conflicts += 1; if (!dryRun) { await recordConflict(row.id, candidates); } continue; } const best = pickBestCandidate(candidates); if (!best) { result.unmatched += 1; continue; } if (!dryRun) { await applyLink(row.id, best.configuration_item_id, best.link_confidence); } result.linked += 1; result.byConfidence[best.link_confidence] += 1; } return result; }