wulf-pulse/lib/services/analyzer/asset-audit/asset-matcher.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

264 lines
7.1 KiB
TypeScript

/**
* Match an analyzer_analyses row to candidate IT Glue assets to audit.
*
* Inputs from the analysis fingerprint:
* - applications_involved → match against itg_flexible_assets.name (Application type)
* - device_classes / vendors_involved + ticket-mentioned device names →
* match against itg_configurations.name + hostname
*
* Same-client only (looked up via companies → itg_organizations name match).
* Score: exact match (3) > word-boundary match (2) > substring (1).
*/
import postgresClient from '@/lib/services/postgres-client';
const APPLICATION_TYPE_ID = 3790;
const PER_KIND_LIMIT = 5;
export interface MatchedFlexibleAsset {
id: string;
name: string | null;
type_name: string | null;
score: number;
matched_term: string;
}
export interface MatchedConfiguration {
id: string;
name: string;
hostname: string | null;
type_name: string | null;
score: number;
matched_term: string;
}
export interface AssetMatchResult {
ticketNumber: string;
organizationId: string | null;
organizationName: string | null;
flexibleAssets: MatchedFlexibleAsset[];
configurations: MatchedConfiguration[];
}
interface AnalysisRow {
ticket_number: string;
fingerprint: Record<string, unknown> | null;
company_id: string | null;
itglue_org_id: string | null;
itglue_org_name: string | null;
}
function normalizeTerm(s: string): string {
return s.toLowerCase().trim();
}
function scoreMatch(haystack: string | null, needle: string): number {
if (!haystack) return 0;
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
if (h === n) return 3;
// Word boundary: surrounded by non-alphanum or start/end.
const re = new RegExp(`(^|[^a-z0-9])${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`);
if (re.test(h)) return 2;
if (h.includes(n)) return 1;
return 0;
}
function dedupeByKey<T extends { id: string }>(items: T[]): T[] {
const seen = new Map<string, T>();
for (const item of items) {
const existing = seen.get(item.id);
if (
!existing ||
('score' in item &&
'score' in existing &&
(item as unknown as { score: number }).score >
(existing as unknown as { score: number }).score)
) {
seen.set(item.id, item);
}
}
return Array.from(seen.values());
}
/**
* Pull the analysis + ticket + IT Glue org mapping in one round-trip.
* Returns null if the analysis isn't complete or the company can't be
* mapped to an IT Glue organization.
*/
async function loadAnalysisContext(analysisId: string): Promise<AnalysisRow | null> {
const res = await postgresClient.query<AnalysisRow>(
`SELECT aa.ticket_number,
aa.aggregate_fingerprint AS fingerprint,
t.company_id::text AS company_id,
o.id::text AS itglue_org_id,
o.name AS itglue_org_name
FROM analyzer_analyses aa
JOIN tickets t ON t.ticket_number = aa.ticket_number
LEFT JOIN companies c ON c.id = t.company_id
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
WHERE aa.id = $1
AND aa.status = 'complete'
LIMIT 1`,
[analysisId]
);
if (res.rowCount === 0) return null;
return res.rows[0];
}
interface FlexAssetCandidate {
id: string;
name: string | null;
type_name: string | null;
}
interface ConfigCandidate {
id: string;
name: string;
hostname: string | null;
type_name: string | null;
}
async function loadFlexAssetCandidates(orgId: string): Promise<FlexAssetCandidate[]> {
const res = await postgresClient.query<FlexAssetCandidate>(
`SELECT id::text AS id,
name,
flexible_asset_type_name AS type_name
FROM itg_flexible_assets
WHERE organization_id = $1
AND flexible_asset_type_id = $2
AND COALESCE(archived, false) = false`,
[orgId, APPLICATION_TYPE_ID]
);
return res.rows;
}
async function loadConfigurationCandidates(
orgId: string
): Promise<ConfigCandidate[]> {
const res = await postgresClient.query<ConfigCandidate>(
`SELECT id::text AS id,
name, hostname,
configuration_type_name AS type_name
FROM itg_configurations
WHERE organization_id = $1`,
[orgId]
);
return res.rows;
}
function fingerprintTerms(fp: Record<string, unknown> | null): string[] {
if (!fp) return [];
const out = new Set<string>();
for (const key of [
'applications_involved',
'device_classes',
'vendors_involved',
]) {
const arr = fp[key];
if (Array.isArray(arr)) {
for (const v of arr) {
if (typeof v === 'string' && v.trim().length > 0) {
out.add(normalizeTerm(v));
}
}
}
}
return Array.from(out);
}
export async function matchAssetsForAnalysis(
analysisId: string
): Promise<AssetMatchResult | null> {
const ctx = await loadAnalysisContext(analysisId);
if (!ctx) return null;
if (!ctx.itglue_org_id) {
return {
ticketNumber: ctx.ticket_number,
organizationId: null,
organizationName: null,
flexibleAssets: [],
configurations: [],
};
}
const terms = fingerprintTerms(ctx.fingerprint);
if (terms.length === 0) {
return {
ticketNumber: ctx.ticket_number,
organizationId: ctx.itglue_org_id,
organizationName: ctx.itglue_org_name,
flexibleAssets: [],
configurations: [],
};
}
const [flexCandidates, configCandidates] = await Promise.all([
loadFlexAssetCandidates(ctx.itglue_org_id),
loadConfigurationCandidates(ctx.itglue_org_id),
]);
const flexHits: MatchedFlexibleAsset[] = [];
for (const c of flexCandidates) {
let bestScore = 0;
let bestTerm = '';
for (const term of terms) {
const s = scoreMatch(c.name, term);
if (s > bestScore) {
bestScore = s;
bestTerm = term;
}
}
if (bestScore > 0) {
flexHits.push({
id: c.id,
name: c.name,
type_name: c.type_name,
score: bestScore,
matched_term: bestTerm,
});
}
}
const configHits: MatchedConfiguration[] = [];
for (const c of configCandidates) {
let bestScore = 0;
let bestTerm = '';
for (const term of terms) {
const sName = scoreMatch(c.name, term);
const sHost = scoreMatch(c.hostname, term);
const s = Math.max(sName, sHost);
if (s > bestScore) {
bestScore = s;
bestTerm = term;
}
}
if (bestScore > 0) {
configHits.push({
id: c.id,
name: c.name,
hostname: c.hostname,
type_name: c.type_name,
score: bestScore,
matched_term: bestTerm,
});
}
}
const sortedFlex = dedupeByKey(flexHits)
.sort((a, b) => b.score - a.score)
.slice(0, PER_KIND_LIMIT);
const sortedConfig = dedupeByKey(configHits)
.sort((a, b) => b.score - a.score)
.slice(0, PER_KIND_LIMIT);
return {
ticketNumber: ctx.ticket_number,
organizationId: ctx.itglue_org_id,
organizationName: ctx.itglue_org_name,
flexibleAssets: sortedFlex,
configurations: sortedConfig,
};
}
export const _MATCHER_INTERNALS = { scoreMatch, fingerprintTerms };