- 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>
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
/**
|
|
* Persistence helpers for `itglue_asset_audits` and `itglue_writes`.
|
|
*
|
|
* Audit rows accumulate full LLM context snapshots (forever-retained).
|
|
* Write rows record every IT Glue PATCH attempt with before/after diffs
|
|
* and provenance back to the audit that prompted the change.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import type {
|
|
AssetAuditResponse,
|
|
AuditConfidence,
|
|
} from '@/lib/types/analyzer';
|
|
|
|
// ─── Audit rows ───────────────────────────────────────────────────────────
|
|
|
|
export type AuditStatus = 'pending' | 'running' | 'complete' | 'failed';
|
|
export type WriteStatus = 'pending' | 'committed' | 'failed' | 'reverted';
|
|
|
|
export interface AssetAuditRow {
|
|
id: string;
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: string;
|
|
asset_type_id: string | null;
|
|
organization_id: string | null;
|
|
generated_by_user_id: string | null;
|
|
generated_at: string;
|
|
provider: 'anthropic' | 'openrouter';
|
|
model_used: string | null;
|
|
asset_snapshot: Record<string, unknown>;
|
|
ticket_count: number;
|
|
field_gaps: AssetAuditResponse['field_gaps'];
|
|
notes_promotions: AssetAuditResponse['notes_promotions'];
|
|
contradictions: AssetAuditResponse['contradictions'];
|
|
overall_score: number | null;
|
|
estimated_cost_usd: number | null;
|
|
total_input_tokens: number | null;
|
|
total_output_tokens: number | null;
|
|
status: AuditStatus;
|
|
error_message: string | null;
|
|
triggered_by_ticket_number: string | null;
|
|
triggered_by_analysis_id: string | null;
|
|
}
|
|
|
|
const AUDIT_SELECT = `
|
|
id::text AS id,
|
|
asset_type, asset_id::text AS asset_id,
|
|
asset_type_id::text AS asset_type_id,
|
|
organization_id::text AS organization_id,
|
|
generated_by_user_id, generated_at,
|
|
provider, model_used,
|
|
asset_snapshot,
|
|
ticket_count,
|
|
field_gaps, notes_promotions, contradictions,
|
|
overall_score::float8 AS overall_score,
|
|
estimated_cost_usd::float8 AS estimated_cost_usd,
|
|
total_input_tokens, total_output_tokens,
|
|
status, error_message,
|
|
triggered_by_ticket_number,
|
|
triggered_by_analysis_id::text AS triggered_by_analysis_id
|
|
`;
|
|
|
|
interface RawAuditRow {
|
|
id: string;
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: string;
|
|
asset_type_id: string | null;
|
|
organization_id: string | null;
|
|
generated_by_user_id: string | null;
|
|
generated_at: Date;
|
|
provider: 'anthropic' | 'openrouter';
|
|
model_used: string | null;
|
|
asset_snapshot: Record<string, unknown>;
|
|
ticket_count: number;
|
|
field_gaps: AssetAuditResponse['field_gaps'];
|
|
notes_promotions: AssetAuditResponse['notes_promotions'];
|
|
contradictions: AssetAuditResponse['contradictions'];
|
|
overall_score: number | null;
|
|
estimated_cost_usd: number | null;
|
|
total_input_tokens: number | null;
|
|
total_output_tokens: number | null;
|
|
status: AuditStatus;
|
|
error_message: string | null;
|
|
triggered_by_ticket_number: string | null;
|
|
triggered_by_analysis_id: string | null;
|
|
}
|
|
|
|
function rowToAudit(r: RawAuditRow): AssetAuditRow {
|
|
return {
|
|
...r,
|
|
generated_at: r.generated_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
export interface CreateAuditInput {
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: number | string;
|
|
asset_type_id: number | null;
|
|
organization_id: number | string | null;
|
|
generated_by_user_id: string | null;
|
|
provider: 'anthropic' | 'openrouter';
|
|
model_used: string;
|
|
asset_snapshot: Record<string, unknown>;
|
|
ticket_count: number;
|
|
response: AssetAuditResponse;
|
|
estimated_cost_usd: number;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
/** Phase 4.1: ticket-first audit linkage. Both null for asset-first audits. */
|
|
triggered_by_ticket_number?: string | null;
|
|
triggered_by_analysis_id?: string | null;
|
|
}
|
|
|
|
export async function insertAssetAudit(input: CreateAuditInput): Promise<{ id: string }> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO itglue_asset_audits
|
|
(asset_type, asset_id, asset_type_id, organization_id,
|
|
generated_by_user_id, provider, model_used,
|
|
asset_snapshot, ticket_count,
|
|
field_gaps, notes_promotions, contradictions,
|
|
overall_score, estimated_cost_usd,
|
|
total_input_tokens, total_output_tokens,
|
|
status,
|
|
triggered_by_ticket_number, triggered_by_analysis_id)
|
|
VALUES ($1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8::jsonb, $9,
|
|
$10::jsonb, $11::jsonb, $12::jsonb,
|
|
$13, $14,
|
|
$15, $16,
|
|
'complete',
|
|
$17, $18)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
input.asset_type,
|
|
input.asset_id,
|
|
input.asset_type_id,
|
|
input.organization_id,
|
|
input.generated_by_user_id,
|
|
input.provider,
|
|
input.model_used,
|
|
JSON.stringify(input.asset_snapshot),
|
|
input.ticket_count,
|
|
JSON.stringify(input.response.field_gaps),
|
|
JSON.stringify(input.response.notes_promotions),
|
|
JSON.stringify(input.response.contradictions),
|
|
input.response.overall_score,
|
|
input.estimated_cost_usd,
|
|
input.total_input_tokens,
|
|
input.total_output_tokens,
|
|
input.triggered_by_ticket_number ?? null,
|
|
input.triggered_by_analysis_id ?? null,
|
|
]
|
|
);
|
|
return { id: res.rows[0].id };
|
|
}
|
|
|
|
export async function insertFailedAssetAudit(input: {
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: number | string;
|
|
asset_type_id: number | null;
|
|
organization_id: number | string | null;
|
|
generated_by_user_id: string | null;
|
|
provider: 'anthropic' | 'openrouter';
|
|
model_used: string | null;
|
|
asset_snapshot: Record<string, unknown>;
|
|
ticket_count: number;
|
|
error_message: string;
|
|
triggered_by_ticket_number?: string | null;
|
|
triggered_by_analysis_id?: string | null;
|
|
}): Promise<{ id: string }> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO itglue_asset_audits
|
|
(asset_type, asset_id, asset_type_id, organization_id,
|
|
generated_by_user_id, provider, model_used,
|
|
asset_snapshot, ticket_count,
|
|
field_gaps, notes_promotions, contradictions,
|
|
status, error_message,
|
|
triggered_by_ticket_number, triggered_by_analysis_id)
|
|
VALUES ($1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8::jsonb, $9,
|
|
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb,
|
|
'failed', $10,
|
|
$11, $12)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
input.asset_type,
|
|
input.asset_id,
|
|
input.asset_type_id,
|
|
input.organization_id,
|
|
input.generated_by_user_id,
|
|
input.provider,
|
|
input.model_used,
|
|
JSON.stringify(input.asset_snapshot),
|
|
input.ticket_count,
|
|
input.error_message,
|
|
input.triggered_by_ticket_number ?? null,
|
|
input.triggered_by_analysis_id ?? null,
|
|
]
|
|
);
|
|
return { id: res.rows[0].id };
|
|
}
|
|
|
|
export async function getLatestAssetAudit(
|
|
assetId: string | number,
|
|
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset'
|
|
): Promise<AssetAuditRow | null> {
|
|
const res = await postgresClient.query<RawAuditRow>(
|
|
`SELECT ${AUDIT_SELECT}
|
|
FROM itglue_asset_audits
|
|
WHERE asset_type = $1
|
|
AND asset_id = $2
|
|
ORDER BY generated_at DESC
|
|
LIMIT 1`,
|
|
[assetType, assetId]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToAudit(res.rows[0]);
|
|
}
|
|
|
|
/**
|
|
* Phase 4.1: fetch the most recent ticket-scoped audit for a given
|
|
* (analysis, assetType, assetId) triple. Used by the analysis page to show
|
|
* "we already audited this asset for this ticket — here's what came back".
|
|
*/
|
|
export async function getLatestTicketScopedAudit(
|
|
analysisId: string,
|
|
assetType: 'flexible_asset' | 'configuration',
|
|
assetId: string | number
|
|
): Promise<AssetAuditRow | null> {
|
|
const res = await postgresClient.query<RawAuditRow>(
|
|
`SELECT ${AUDIT_SELECT}
|
|
FROM itglue_asset_audits
|
|
WHERE asset_type = $1
|
|
AND asset_id = $2
|
|
AND triggered_by_analysis_id = $3
|
|
ORDER BY generated_at DESC
|
|
LIMIT 1`,
|
|
[assetType, assetId, analysisId]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToAudit(res.rows[0]);
|
|
}
|
|
|
|
export async function getAssetAuditById(id: string): Promise<AssetAuditRow | null> {
|
|
const res = await postgresClient.query<RawAuditRow>(
|
|
`SELECT ${AUDIT_SELECT} FROM itglue_asset_audits WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToAudit(res.rows[0]);
|
|
}
|
|
|
|
export async function listAssetAudits(
|
|
assetId: string | number,
|
|
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset',
|
|
limit = 20
|
|
): Promise<AssetAuditRow[]> {
|
|
const res = await postgresClient.query<RawAuditRow>(
|
|
`SELECT ${AUDIT_SELECT}
|
|
FROM itglue_asset_audits
|
|
WHERE asset_type = $1
|
|
AND asset_id = $2
|
|
ORDER BY generated_at DESC
|
|
LIMIT $3`,
|
|
[assetType, assetId, limit]
|
|
);
|
|
return res.rows.map(rowToAudit);
|
|
}
|
|
|
|
// ─── Write rows ───────────────────────────────────────────────────────────
|
|
|
|
export interface AssetWriteRow {
|
|
id: string;
|
|
audit_id: string | null;
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: string;
|
|
field_name: string;
|
|
before_value: unknown;
|
|
after_value: unknown;
|
|
performed_by_user_id: string | null;
|
|
performed_at: string;
|
|
status: WriteStatus;
|
|
itglue_response: unknown;
|
|
error_message: string | null;
|
|
source_evidence: unknown;
|
|
}
|
|
|
|
const WRITE_SELECT = `
|
|
id::text AS id,
|
|
audit_id::text AS audit_id,
|
|
asset_type, asset_id::text AS asset_id,
|
|
field_name,
|
|
before_value, after_value,
|
|
performed_by_user_id, performed_at,
|
|
status,
|
|
itglue_response, error_message, source_evidence
|
|
`;
|
|
|
|
interface RawWriteRow {
|
|
id: string;
|
|
audit_id: string | null;
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: string;
|
|
field_name: string;
|
|
before_value: unknown;
|
|
after_value: unknown;
|
|
performed_by_user_id: string | null;
|
|
performed_at: Date;
|
|
status: WriteStatus;
|
|
itglue_response: unknown;
|
|
error_message: string | null;
|
|
source_evidence: unknown;
|
|
}
|
|
|
|
function rowToWrite(r: RawWriteRow): AssetWriteRow {
|
|
return { ...r, performed_at: r.performed_at.toISOString() };
|
|
}
|
|
|
|
export async function createPendingWrite(input: {
|
|
audit_id: string | null;
|
|
asset_type: 'flexible_asset' | 'configuration';
|
|
asset_id: number | string;
|
|
field_name: string;
|
|
before_value: unknown;
|
|
after_value: unknown;
|
|
performed_by_user_id: string | null;
|
|
source_evidence: unknown;
|
|
/** Phase 4.1: ticket linkage carried forward from the audit row. */
|
|
triggered_by_ticket_number?: string | null;
|
|
}): Promise<{ id: string }> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO itglue_writes
|
|
(audit_id, asset_type, asset_id, field_name,
|
|
before_value, after_value,
|
|
performed_by_user_id, status, source_evidence,
|
|
triggered_by_ticket_number)
|
|
VALUES ($1, $2, $3, $4,
|
|
$5::jsonb, $6::jsonb,
|
|
$7, 'pending', $8::jsonb,
|
|
$9)
|
|
RETURNING id::text AS id`,
|
|
[
|
|
input.audit_id,
|
|
input.asset_type,
|
|
input.asset_id,
|
|
input.field_name,
|
|
JSON.stringify(input.before_value ?? null),
|
|
JSON.stringify(input.after_value),
|
|
input.performed_by_user_id,
|
|
JSON.stringify(input.source_evidence ?? null),
|
|
input.triggered_by_ticket_number ?? null,
|
|
]
|
|
);
|
|
return { id: res.rows[0].id };
|
|
}
|
|
|
|
export async function markWriteCommitted(
|
|
id: string,
|
|
itglueResponse: unknown
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE itglue_writes
|
|
SET status = 'committed',
|
|
itglue_response = $2::jsonb
|
|
WHERE id = $1`,
|
|
[id, JSON.stringify(itglueResponse ?? null)]
|
|
);
|
|
}
|
|
|
|
export async function markWriteFailed(id: string, errorMessage: string): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE itglue_writes
|
|
SET status = 'failed', error_message = $2
|
|
WHERE id = $1`,
|
|
[id, errorMessage]
|
|
);
|
|
}
|
|
|
|
export async function markWriteReverted(id: string): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE itglue_writes SET status = 'reverted' WHERE id = $1`,
|
|
[id]
|
|
);
|
|
}
|
|
|
|
export async function getWriteById(id: string): Promise<AssetWriteRow | null> {
|
|
const res = await postgresClient.query<RawWriteRow>(
|
|
`SELECT ${WRITE_SELECT} FROM itglue_writes WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToWrite(res.rows[0]);
|
|
}
|
|
|
|
export async function listWritesForAsset(
|
|
assetId: string | number,
|
|
limit = 50
|
|
): Promise<AssetWriteRow[]> {
|
|
const res = await postgresClient.query<RawWriteRow>(
|
|
`SELECT ${WRITE_SELECT}
|
|
FROM itglue_writes
|
|
WHERE asset_type = 'flexible_asset'
|
|
AND asset_id = $1
|
|
ORDER BY performed_at DESC
|
|
LIMIT $2`,
|
|
[assetId, limit]
|
|
);
|
|
return res.rows.map(rowToWrite);
|
|
}
|
|
|
|
export async function listAllWrites(opts: {
|
|
limit?: number;
|
|
offset?: number;
|
|
status?: WriteStatus;
|
|
}): Promise<AssetWriteRow[]> {
|
|
const limit = Math.min(opts.limit ?? 100, 500);
|
|
const offset = opts.offset ?? 0;
|
|
const params: unknown[] = [limit, offset];
|
|
let where = '';
|
|
if (opts.status) {
|
|
params.push(opts.status);
|
|
where = `WHERE status = $${params.length}`;
|
|
}
|
|
const res = await postgresClient.query<RawWriteRow>(
|
|
`SELECT ${WRITE_SELECT}
|
|
FROM itglue_writes
|
|
${where}
|
|
ORDER BY performed_at DESC
|
|
LIMIT $1 OFFSET $2`,
|
|
params
|
|
);
|
|
return res.rows.map(rowToWrite);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The IT Glue convention: trait keys are field names lowercased, hyphenated,
|
|
* stripped of repeated/leading/trailing hyphens. Used to map a human field
|
|
* name (e.g. "Wulf Application Champion(s)") to its trait key.
|
|
*/
|
|
export function fieldNameToTraitKey(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
export type { AssetAuditResponse, AuditConfidence };
|