/** * 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; 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; 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; 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; 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 { const res = await postgresClient.query( `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 { const res = await postgresClient.query( `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 { const res = await postgresClient.query( `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 { const res = await postgresClient.query( `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 { 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 { await postgresClient.query( `UPDATE itglue_writes SET status = 'failed', error_message = $2 WHERE id = $1`, [id, errorMessage] ); } export async function markWriteReverted(id: string): Promise { await postgresClient.query( `UPDATE itglue_writes SET status = 'reverted' WHERE id = $1`, [id] ); } export async function getWriteById(id: string): Promise { const res = await postgresClient.query( `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 { const res = await postgresClient.query( `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 { 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( `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 };