/** * Analyzer persistence: read/write to analyzer_analyses, analyzer_jobs, and * analyzer_shares. * * All writes go through the postgresClient singleton — no transactions are * needed for the row-per-analysis writes since each is independent and the * unique (ticket_number, analysis_version) constraint prevents duplicates. */ import postgresClient from '@/lib/services/postgres-client'; import { type AnalyzerJob, type DeepAnalysisResponse, type JobStatus, type PersistedAnalysis, type TaggedEvent, } from '@/lib/types/analyzer'; import type { ITGlueDocReference } from '@/lib/types/analyzer'; export interface InsertAnalysisInput { ticket_number: string; autotask_ticket_id: number; content_hash: string; triggered_by_user_id: string | null; status: 'complete' | 'failed'; /** When the analysis run finished (now() if undefined). */ completed_at?: Date; haiku_used: boolean; sonnet_used: boolean; opus_used: boolean; total_input_tokens: number; total_output_tokens: number; estimated_cost_usd: number; /** Final analysis content (after any Opus updates). null on failure. */ analysis: DeepAnalysisResponse | null; filtered_noise_count: number; /** Per-stage trace dump for debugging — raw model responses, attempts, etc. */ model_traces: Record; error_message?: string | null; } /** * Returns the next monotonic analysis_version for this ticket. Uses MAX(...)+1 * — there is a small race if two workers call this simultaneously, but the * UNIQUE (ticket_number, analysis_version) constraint catches it: the loser * sees a 23505 unique_violation and the worker should retry with a fresh * version number. */ export async function getNextAnalysisVersion(ticketNumber: string): Promise { const res = await postgresClient.query<{ next_version: string }>( `SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version FROM analyzer_analyses WHERE ticket_number = $1`, [ticketNumber] ); return Number(res.rows[0].next_version); } /** * Idempotency check: returns the most recent COMPLETE analysis row whose * content_hash matches, if any. Used to short-circuit re-runs when the source * data hasn't changed and `force=false`. */ export async function findExistingAnalysisByContentHash( ticketNumber: string, contentHash: string ): Promise<{ id: string; analysis_version: number } | null> { const res = await postgresClient.query<{ id: string; analysis_version: string }>( `SELECT id::text AS id, analysis_version::text AS analysis_version FROM analyzer_analyses WHERE ticket_number = $1 AND content_hash_at_analysis = $2 AND status = 'complete' ORDER BY analysis_version DESC LIMIT 1`, [ticketNumber, contentHash] ); if (res.rowCount === 0) return null; const row = res.rows[0]; return { id: row.id, analysis_version: Number(row.analysis_version) }; } /** * Insert a completed (or failed) analysis row. Returns the new row's id. * * Note: the unique (ticket_number, analysis_version) constraint catches racing * writers. Caller should re-fetch the next version and retry if it sees a * unique-violation error from postgres. */ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{ id: string; analysis_version: number; }> { const version = await getNextAnalysisVersion(input.ticket_number); const completedAt = input.completed_at ?? new Date(); const a = input.analysis; const res = await postgresClient.query<{ id: string }>( ` INSERT INTO analyzer_analyses ( ticket_number, autotask_ticket_id, analysis_version, content_hash_at_analysis, triggered_by_user_id, status, completed_at, haiku_used, sonnet_used, opus_used, total_input_tokens, total_output_tokens, estimated_cost_usd, summary, timeline, what_was_done, what_should_have_been_done, gaps, next_step, next_step_rationale, post_resolution_analysis, confidence_score, needs_human_review, human_review_reasons, itglue_docs_referenced, model_traces, filtered_noise_count, error_message ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16::jsonb, $17::jsonb, $18::jsonb, $19, $20, $21, $22, $23, $24::jsonb, $25::jsonb, $26::jsonb, $27, $28 ) RETURNING id::text AS id `, [ input.ticket_number, input.autotask_ticket_id, version, input.content_hash, input.triggered_by_user_id, input.status, completedAt, input.haiku_used, input.sonnet_used, input.opus_used, input.total_input_tokens, input.total_output_tokens, input.estimated_cost_usd, a?.summary ?? null, a?.timeline ? JSON.stringify(a.timeline) : null, a?.what_was_done ? JSON.stringify(a.what_was_done) : null, a?.what_should_have_been_done ? JSON.stringify(a.what_should_have_been_done) : null, a?.gaps ? JSON.stringify(a.gaps) : null, a?.next_step ?? null, a?.next_step_rationale ?? null, a?.post_resolution_analysis ?? null, a?.confidence_score ?? null, a?.needs_human_review ?? false, a?.human_review_reasons ? JSON.stringify(a.human_review_reasons) : null, JSON.stringify(a?.itglue_docs_referenced ?? []), JSON.stringify(input.model_traces), input.filtered_noise_count, input.error_message ?? null, ] ); return { id: res.rows[0].id, analysis_version: version }; } // ============================================================================= // Job table operations // ============================================================================= /** * Try to claim the oldest queued job. Atomic via UPDATE ... WHERE ... RETURNING. * Returns null if no queued jobs are available. */ export async function claimQueuedJob(): Promise<{ id: string; ticket_number: string; queued_by_user_id: string | null; } | null> { const res = await postgresClient.query<{ id: string; ticket_number: string; queued_by_user_id: string | null; }>( ` UPDATE analyzer_jobs SET status = 'fetching', started_at = NOW() WHERE id = ( SELECT id FROM analyzer_jobs WHERE status = 'queued' ORDER BY queued_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING id::text AS id, ticket_number, queued_by_user_id ` ); if (res.rowCount === 0) return null; return res.rows[0]; } export async function updateJobStatus( jobId: string, status: JobStatus ): Promise { await postgresClient.query( `UPDATE analyzer_jobs SET status = $1 WHERE id = $2`, [status, jobId] ); } export async function completeJob( jobId: string, resultAnalysisId: string ): Promise { await postgresClient.query( `UPDATE analyzer_jobs SET status = 'complete', result_analysis_id = $1, finished_at = NOW() WHERE id = $2`, [resultAnalysisId, jobId] ); } export async function failJob(jobId: string, errorMessage: string): Promise { await postgresClient.query( `UPDATE analyzer_jobs SET status = 'failed', error_message = $1, finished_at = NOW() WHERE id = $2`, [errorMessage, jobId] ); } export interface QueueJobInput { ticket_number: string; queued_by_user_id: string | null; } export async function queueJob(input: QueueJobInput): Promise<{ id: string }> { const res = await postgresClient.query<{ id: string }>( `INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id) VALUES ($1, $2) RETURNING id::text AS id`, [input.ticket_number, input.queued_by_user_id] ); return { id: res.rows[0].id }; } export async function getJob(jobId: string): Promise { const res = await postgresClient.query<{ id: string; ticket_number: string; queued_by_user_id: string | null; status: JobStatus; result_analysis_id: string | null; queued_at: Date; started_at: Date | null; finished_at: Date | null; error_message: string | null; }>( `SELECT id::text AS id, ticket_number, queued_by_user_id, status, result_analysis_id::text AS result_analysis_id, queued_at, started_at, finished_at, error_message FROM analyzer_jobs WHERE id = $1`, [jobId] ); if (res.rowCount === 0) return null; const r = res.rows[0]; return { id: r.id, ticketNumber: r.ticket_number, queuedByUserId: r.queued_by_user_id, status: r.status, resultAnalysisId: r.result_analysis_id, queuedAt: r.queued_at.toISOString(), startedAt: r.started_at ? r.started_at.toISOString() : null, finishedAt: r.finished_at ? r.finished_at.toISOString() : null, errorMessage: r.error_message, }; } // ============================================================================= // Read paths used by the API routes // ============================================================================= interface AnalysisRow { id: string; ticket_number: string; autotask_ticket_id: string; analysis_version: string; content_hash_at_analysis: string; triggered_by_user_id: string | null; triggered_at: Date; status: PersistedAnalysis['status']; completed_at: Date | null; haiku_used: boolean; sonnet_used: boolean; opus_used: boolean; total_input_tokens: number; total_output_tokens: number; estimated_cost_usd: string; summary: string | null; timeline: unknown; what_was_done: unknown; what_should_have_been_done: unknown; gaps: unknown; next_step: string | null; next_step_rationale: string | null; post_resolution_analysis: string | null; confidence_score: string | null; needs_human_review: boolean; human_review_reasons: unknown; itglue_docs_referenced: unknown; filtered_noise_count: number; error_message: string | null; } function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis { return { id: r.id, ticketNumber: r.ticket_number, autotaskTicketId: Number(r.autotask_ticket_id), analysisVersion: Number(r.analysis_version), contentHashAtAnalysis: r.content_hash_at_analysis, triggeredByUserId: r.triggered_by_user_id, triggeredAt: r.triggered_at.toISOString(), status: r.status, completedAt: r.completed_at ? r.completed_at.toISOString() : null, haikuUsed: r.haiku_used, sonnetUsed: r.sonnet_used, opusUsed: r.opus_used, totalInputTokens: r.total_input_tokens, totalOutputTokens: r.total_output_tokens, estimatedCostUsd: Number(r.estimated_cost_usd), summary: r.summary, // JSONB columns deserialize directly to JS objects in node-postgres; cast // to the schema type. We trust Zod-validated writes from the pipeline. timeline: r.timeline as PersistedAnalysis['timeline'], whatWasDone: r.what_was_done as PersistedAnalysis['whatWasDone'], whatShouldHaveBeenDone: r.what_should_have_been_done as PersistedAnalysis['whatShouldHaveBeenDone'], gaps: r.gaps as PersistedAnalysis['gaps'], nextStep: r.next_step, nextStepRationale: r.next_step_rationale, postResolutionAnalysis: r.post_resolution_analysis, confidenceScore: r.confidence_score === null ? null : Number(r.confidence_score), needsHumanReview: r.needs_human_review, humanReviewReasons: r.human_review_reasons as | PersistedAnalysis['humanReviewReasons'], itglueDocsReferenced: (r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [], filteredNoiseCount: r.filtered_noise_count, errorMessage: r.error_message, }; } const ANALYSIS_SELECT = ` id::text AS id, ticket_number, autotask_ticket_id::text AS autotask_ticket_id, analysis_version::text AS analysis_version, content_hash_at_analysis, triggered_by_user_id, triggered_at, status, completed_at, haiku_used, sonnet_used, opus_used, total_input_tokens, total_output_tokens, estimated_cost_usd::text AS estimated_cost_usd, summary, timeline, what_was_done, what_should_have_been_done, gaps, next_step, next_step_rationale, post_resolution_analysis, confidence_score::text AS confidence_score, needs_human_review, human_review_reasons, itglue_docs_referenced, filtered_noise_count, error_message `; export async function getAnalysisById( id: string ): Promise { const res = await postgresClient.query( `SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE id = $1`, [id] ); if (res.rowCount === 0) return null; return rowToPersistedAnalysis(res.rows[0]); } export async function listAnalysesByTicketNumber( ticketNumber: string ): Promise { const res = await postgresClient.query( `SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE ticket_number = $1 ORDER BY analysis_version DESC`, [ticketNumber] ); return res.rows.map(rowToPersistedAnalysis); } export async function listNeedsReview(opts: { limit?: number; offset?: number; } = {}): Promise { const limit = Math.min(opts.limit ?? 50, 200); const offset = opts.offset ?? 0; const res = await postgresClient.query( `SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE needs_human_review = true AND status = 'complete' ORDER BY triggered_at DESC LIMIT $1 OFFSET $2`, [limit, offset] ); return res.rows.map(rowToPersistedAnalysis); } // ============================================================================= // Share log // ============================================================================= export interface CreateShareInput { analysis_id: string; shared_by_user_id: string; shared_with_email: string; note?: string | null; } export async function createShare( input: CreateShareInput ): Promise<{ id: string; shared_at: string }> { const res = await postgresClient.query<{ id: string; shared_at: Date }>( `INSERT INTO analyzer_shares (analysis_id, shared_by_user_id, shared_with_email, note) VALUES ($1, $2, $3, $4) RETURNING id::text AS id, shared_at`, [ input.analysis_id, input.shared_by_user_id, input.shared_with_email, input.note ?? null, ] ); return { id: res.rows[0].id, shared_at: res.rows[0].shared_at.toISOString(), }; } // Re-export types referenced elsewhere. export type { TaggedEvent, ITGlueDocReference, PersistedAnalysis };