- 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>
223 lines
6.5 KiB
TypeScript
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);
|
|
}
|