wulf-pulse/lib/services/analyzer/asset-audit/xrefs.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

223 lines
6.5 KiB
TypeScript

/**
* Cross-reference persistence between tickets and IT Glue assets.
*
* Three relationship types:
* - 'referenced' — the analyzer cited this asset/doc when
* analyzing the ticket (from
* analyzer_analyses.itglue_docs_referenced)
* - 'updated' — a ticket-driven audit produced a write to
* the asset
* - 'should_have_referenced' — a gap text suggests we needed this asset/doc
* but didn't find it (reserved for future use;
* not populated automatically yet)
*
* Inserts use ON CONFLICT DO NOTHING against the unique index so re-runs and
* idempotent retries don't pollute the table.
*/
import postgresClient from '@/lib/services/postgres-client';
export type XrefAssetType = 'flexible_asset' | 'configuration' | 'document';
export type XrefRelationship = 'referenced' | 'updated' | 'should_have_referenced';
export type XrefSource = 'analyzer_referenced' | 'audit_write' | 'manual';
export type XrefConfidence = 'high' | 'medium' | 'low' | null;
export interface XrefRow {
id: string;
ticketNumber: string;
analysisId: string | null;
assetType: XrefAssetType;
assetId: string;
relationship: XrefRelationship;
source: XrefSource;
confidence: XrefConfidence;
details: unknown;
createdAt: string;
}
interface RawXrefRow {
id: string;
ticket_number: string;
analysis_id: string | null;
asset_type: XrefAssetType;
asset_id: string;
relationship: XrefRelationship;
source: XrefSource;
confidence: XrefConfidence;
details: unknown;
created_at: Date;
}
const XREF_SELECT = `
id::text AS id,
ticket_number,
analysis_id::text AS analysis_id,
asset_type,
asset_id::text AS asset_id,
relationship, source, confidence,
details,
created_at
`;
function rowToXref(r: RawXrefRow): XrefRow {
return {
id: r.id,
ticketNumber: r.ticket_number,
analysisId: r.analysis_id,
assetType: r.asset_type,
assetId: r.asset_id,
relationship: r.relationship,
source: r.source,
confidence: r.confidence,
details: r.details,
createdAt: r.created_at.toISOString(),
};
}
// ─── Inserts ──────────────────────────────────────────────────────────────
interface InsertXrefRowInput {
ticketNumber: string;
analysisId: string | null;
assetType: XrefAssetType;
assetId: string | number;
relationship: XrefRelationship;
source: XrefSource;
confidence?: XrefConfidence;
details?: unknown;
}
export async function insertXref(input: InsertXrefRowInput): Promise<void> {
await postgresClient.query(
`INSERT INTO itglue_ticket_xrefs
(ticket_number, analysis_id, asset_type, asset_id,
relationship, source, confidence, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
ON CONFLICT DO NOTHING`,
[
input.ticketNumber,
input.analysisId,
input.assetType,
input.assetId,
input.relationship,
input.source,
input.confidence ?? null,
JSON.stringify(input.details ?? null),
]
);
}
/**
* Bulk-insert xref rows from an analyzer_analyses.itglue_docs_referenced
* payload. Each entry is an ITGlueDocReference: { id, name, url, doc_type,
* relevance_reason }. We map doc_type → xref asset_type.
*/
export interface AnalyzerDocReference {
id: string;
name?: string | null;
url?: string | null;
doc_type?: string | null;
relevance_reason?: string | null;
}
function mapDocTypeToAssetType(docType: string | null | undefined): XrefAssetType | null {
if (!docType) return null;
const t = docType.toLowerCase();
if (t === 'flexible_asset' || t === 'flexible-asset' || t === 'flex_asset') return 'flexible_asset';
if (t === 'configuration') return 'configuration';
if (t === 'document') return 'document';
return null;
}
export async function insertReferencedXrefsFromAnalysis(input: {
ticketNumber: string;
analysisId: string;
references: AnalyzerDocReference[];
}): Promise<{ inserted: number; skipped: number }> {
let inserted = 0;
let skipped = 0;
for (const ref of input.references) {
const assetType = mapDocTypeToAssetType(ref.doc_type ?? null);
if (!assetType) {
skipped += 1;
continue;
}
const numericId = Number(ref.id);
if (!Number.isFinite(numericId)) {
skipped += 1;
continue;
}
await insertXref({
ticketNumber: input.ticketNumber,
analysisId: input.analysisId,
assetType,
assetId: numericId,
relationship: 'referenced',
source: 'analyzer_referenced',
confidence: 'high',
details: {
name: ref.name ?? null,
url: ref.url ?? null,
relevance_reason: ref.relevance_reason ?? null,
},
});
inserted += 1;
}
return { inserted, skipped };
}
export async function insertUpdatedXref(input: {
ticketNumber: string;
analysisId: string | null;
assetType: 'flexible_asset' | 'configuration';
assetId: string | number;
writeId: string;
fieldName: string;
}): Promise<void> {
await insertXref({
ticketNumber: input.ticketNumber,
analysisId: input.analysisId,
assetType: input.assetType,
assetId: input.assetId,
relationship: 'updated',
source: 'audit_write',
confidence: 'high',
details: {
write_id: input.writeId,
field_name: input.fieldName,
},
});
}
// ─── Queries ──────────────────────────────────────────────────────────────
export async function listXrefsForAsset(
assetType: 'flexible_asset' | 'configuration',
assetId: string | number,
limit = 100
): Promise<XrefRow[]> {
const res = await postgresClient.query<RawXrefRow>(
`SELECT ${XREF_SELECT}
FROM itglue_ticket_xrefs
WHERE asset_type = $1 AND asset_id = $2
ORDER BY created_at DESC
LIMIT $3`,
[assetType, assetId, limit]
);
return res.rows.map(rowToXref);
}
export async function listXrefsForTicket(
ticketNumber: string,
limit = 100
): Promise<XrefRow[]> {
const res = await postgresClient.query<RawXrefRow>(
`SELECT ${XREF_SELECT}
FROM itglue_ticket_xrefs
WHERE ticket_number = $1
ORDER BY created_at DESC
LIMIT $2`,
[ticketNumber, limit]
);
return res.rows.map(rowToXref);
}