collabSupport/services/enrichment/redMatcher.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

98 lines
3.8 KiB
JavaScript

// services/enrichment/redMatcher.js
//
// Unified RED player matching for AV enrichment.
// This is the single source of truth for finding which RED player (if any)
// corresponds to a given MDM/AV device (mainly the MSC* ones).
//
// Consolidates logic previously duplicated in:
// - deviceService.js (enrichMdmDevices, with special ae/offline/aerie cases)
// - avDeviceBuilder.js (enrichDomainData, with translate + includes)
// - avEnricher.js (findRedMatch, which was weaker for dotted DeviceIDs)
//
// Uses translateREDDeviceID (and normalizePlayerName via barrel) from normalizers.
// Extracted as part of Option A incremental consolidation.
import { normalizePlayerName, translateREDDeviceID } from './normalizers.js';
/**
* Find the best matching full RED player object for a source identifier.
* Identifier can be a friendlyName, device.identifier, etc. (e.g. "US002477MSCOFF"
* or "US002477MSCAE").
*
* Handles RED DeviceIDs in formats like:
* - "US.OFFLINE.2477" -> matches US002477MSCOFF devices
* - "US.AE.1234" -> matches US001234MSCAE
* - "US.AERIE.999"
* - Also falls back to Name field contains, and direct matches.
*
* @param {string} identifier
* @param {Array<Object>} redList - raw players from getREDStatusForStore (full objects)
* @returns {Object|null} matching RED player (full data, including AvailabilityStatus etc.) or null
*/
export function findBestRedMatch(identifier, redList) {
if (!identifier || !Array.isArray(redList) || redList.length === 0) {
return null;
}
// Canonical form for the AV/MDM side (e.g. US002477MSCOFF)
const idUpper = normalizePlayerName(identifier).toUpperCase().trim();
if (!idUpper) return null;
// Helper: does hay contain id as a "whole" token (not as prefix of longer brand like MSCAE inside MSCAERIE)
function containsWhole(id, hay) {
if (!hay || !id) return false;
let idx = -1;
while ((idx = hay.indexOf(id, idx + 1)) !== -1) {
const after = hay[idx + id.length];
if (after === undefined || !/[A-Z0-9]/.test(after)) {
return true;
}
}
return false;
}
// Strategy 1 (preferred for translated cases): Translate RED DeviceID using the canonical translator and exact match.
// This is the reliable path for OFFLINE/AE/AERIE branded players (US.OFFLINE.2477 etc.)
// and covers all the previous ad-hoc special cases in deviceService.
for (const redPlayer of redList) {
const translated = translateREDDeviceID(redPlayer.DeviceID);
if (translated && translated.toUpperCase() === idUpper) {
return redPlayer;
}
}
// Strategy 2: Direct "whole token" match against RED's DeviceID or Name (preserves original builder heuristic,
// but avoids false positives on overlapping suffixes like MSCAE vs MSCAERIE).
let match = redList.find(p => {
const did = (p.DeviceID || '').toUpperCase();
const nm = (p.Name || '').toUpperCase();
return containsWhole(idUpper, did) || containsWhole(idUpper, nm);
});
if (match) {
return match;
}
// Strategy 3: Fallback using normalizePlayerName on the RED side too (exact after norm).
// This preserves (and may slightly improve) the previous avEnricher findRedMatch behavior
// for RED records that are already in a "clean" form. For dotted DeviceIDs this alone
// would have missed before (normalize on "US.OFFLINE.2477" gives "US002477"), but
// Strategy 2 will have caught it.
const targetLower = idUpper.toLowerCase();
match = redList.find(r => {
const fromRed = normalizePlayerName(r.DeviceID || r.Name || '').toLowerCase().trim();
return fromRed === targetLower;
});
if (match) {
return match;
}
return null;
}
/**
* Backward-compat alias (used internally by avEnricher etc. during migration).
* New code should prefer findBestRedMatch.
*/
export function findRedMatch(name, redList) {
return findBestRedMatch(name, redList);
}