import { NextRequest, NextResponse } from 'next/server'; import Anthropic from '@anthropic-ai/sdk'; import postgresClient from '@/lib/services/postgres-client'; import { analysisState } from '@/lib/services/veeam-analysis-state'; const MODEL = 'claude-haiku-4-5-20251001'; const CONCURRENCY = 4; const HUMAN_NOTE_TYPES = [1, 2, 3]; const SYSTEM_PROMPT = `You are analyzing Autotask service tickets for Veeam backup failures to help an MSP build process and procedure documentation. For each ticket, determine: 1. The root cause category of the backup failure 2. How it was ultimately resolved 3. What specific technical skills were required 4. Whether the device was offline/powered-off when the alert fired 5. Whether the backup completed on its own before any technician touched the ticket 6. Whether this specific issue was preventable with better process or monitoring 7. Resolution complexity Return JSON only — no explanation, no markdown: { "problem_category": "device_offline" | "agent_issue" | "job_failed" | "storage_issue" | "network_issue" | "authentication" | "software_error" | "self_resolved" | "configuration" | "other", "resolution_type": "no_action_needed" | "device_powered_on" | "backup_restarted" | "agent_reinstalled" | "storage_cleared" | "settings_updated" | "escalated" | "other", "skills_required": ["array", "of", "specific", "skill", "strings"], "complexity": "trivial" | "low" | "medium" | "high", "device_was_offline": true | false, "backup_completed_before_tech": true | false, "preventable": true | false, "work_summary": "1-2 sentence description of what happened and what was done", "recommended_procedure": "one concrete SOP action for this class of issue" }`; async function analyzeTicket(client: Anthropic, ticket: any): Promise { const sameDayClose = !!(ticket.completed_date && new Date(ticket.create_date).toDateString() === new Date(ticket.completed_date).toDateString()); const [notesRes, timeRes, offlineRes] = await Promise.all([ postgresClient.query(` SELECT tn.note_type, tn.title, tn.description, tn.create_date_time, r.first_name || ' ' || r.last_name AS author FROM ticket_notes tn LEFT JOIN resources r ON r.id = tn.creator_resource_id WHERE tn.ticket_id = $1 AND tn.note_type = ANY($2) AND tn.is_deleted = false ORDER BY tn.create_date_time `, [ticket.ticket_id, HUMAN_NOTE_TYPES]), postgresClient.query(` SELECT te.hours_worked, te.notes, te.entry_date, r.first_name || ' ' || r.last_name AS tech FROM time_entries te LEFT JOIN resources r ON r.id = te.resource_id WHERE te.ticket_id = $1 ORDER BY te.entry_date `, [ticket.ticket_id]), ticket.device_hostname ? postgresClient.query(` SELECT hours_offline, rmm_last_seen FROM veeam_rpo_offline_log WHERE LOWER(rmm_hostname) = LOWER($1) AND checked_at BETWEEN $2::timestamptz - INTERVAL '72 hours' AND $2::timestamptz + INTERVAL '24 hours' ORDER BY checked_at DESC LIMIT 1 `, [ticket.device_hostname, ticket.create_date]) : Promise.resolve({ rows: [] }), ]); const notes = notesRes.rows; const entries = timeRes.rows; const offline = offlineRes.rows[0] ?? null; const totalHours = entries.reduce((s: number, e: any) => s + parseFloat(e.hours_worked ?? 0), 0); const notesText = notes.length > 0 ? notes.map((n: any) => `[${n.author ?? 'Unknown'} — ${new Date(n.create_date_time).toLocaleDateString()}]\n` + `${n.title ? n.title + '\n' : ''}${(n.description ?? '').substring(0, 600)}` ).join('\n\n---\n\n') : '(No tech notes)'; const timeText = entries.length > 0 ? entries.map((e: any) => `${e.tech ?? 'Unknown'}: ${e.hours_worked}h — ${(e.notes ?? '').substring(0, 300)}` ).join('\n') : '(No time entries)'; const offlineCtx = offline ? `RMM offline data: device was last seen ${Math.round(offline.hours_offline)}h before ticket creation (last seen ${new Date(offline.rmm_last_seen).toLocaleDateString()}).` : 'No RMM offline record found for this device around ticket creation time.'; const userPrompt = `Ticket: ${ticket.ticket_number} Title: ${ticket.title} Client: ${ticket.company_name ?? 'Unknown'} Device: ${ticket.device_hostname ?? 'Unknown'} Created: ${new Date(ticket.create_date).toLocaleDateString()} Closed: ${ticket.completed_date ? new Date(ticket.completed_date).toLocaleDateString() : 'Still open'} Same-day close: ${sameDayClose} Time logged: ${totalHours.toFixed(2)}h ${offlineCtx} Tech notes: ${notesText} Time entries: ${timeText}`; const message = await client.messages.create({ model: MODEL, max_tokens: 512, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: userPrompt }], }); const raw = message.content[0].type === 'text' ? message.content[0].text : '{}'; const json = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim(); let a: Record = {}; try { a = JSON.parse(json); } catch { a = {}; } await postgresClient.query(` INSERT INTO veeam_ticket_analysis ( ticket_number, ticket_id, company_id, company_name, device_hostname, ticket_created_at, ticket_closed_at, same_day_close, hours_worked, note_count, problem_category, resolution_type, skills_required, complexity, device_was_offline, backup_completed_before_tech, preventable, work_summary, recommended_procedure, model ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT (ticket_number) DO UPDATE SET problem_category = EXCLUDED.problem_category, resolution_type = EXCLUDED.resolution_type, skills_required = EXCLUDED.skills_required, complexity = EXCLUDED.complexity, device_was_offline = EXCLUDED.device_was_offline, backup_completed_before_tech = EXCLUDED.backup_completed_before_tech, preventable = EXCLUDED.preventable, work_summary = EXCLUDED.work_summary, recommended_procedure = EXCLUDED.recommended_procedure, hours_worked = EXCLUDED.hours_worked, note_count = EXCLUDED.note_count, analyzed_at = NOW() `, [ ticket.ticket_number, ticket.ticket_id, ticket.company_id, ticket.company_name, ticket.device_hostname, ticket.create_date, ticket.completed_date, sameDayClose, totalHours, notes.length, a.problem_category ?? 'other', a.resolution_type ?? 'other', a.skills_required ?? [], a.complexity ?? 'low', a.device_was_offline ?? null, a.backup_completed_before_tech ?? null, a.preventable ?? null, a.work_summary ?? null, a.recommended_procedure ?? null, MODEL, ]); } async function runBatch(tickets: any[]): Promise { const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }); for (let i = 0; i < tickets.length; i += CONCURRENCY) { const chunk = tickets.slice(i, i + CONCURRENCY); await Promise.allSettled( chunk.map(t => analyzeTicket(client, t) .then(() => { analysisState.done++; }) .catch(err => { console.error(`[VEEAM-ANALYSIS] ${t.ticket_number}:`, err.message); analysisState.errors++; analysisState.done++; }) ) ); if (i + CONCURRENCY < tickets.length) { await new Promise(r => setTimeout(r, 150)); } } } export async function POST(req: NextRequest) { if (analysisState.isRunning) { return NextResponse.json({ error: 'Analysis already running', progress: analysisState, }, { status: 409 }); } const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 }); } const body = await req.json().catch(() => ({})); const reanalyze = body.reanalyze === true; const ticketsRes = await postgresClient.query(` SELECT DISTINCT ON (t.id) t.id AS ticket_id, t.ticket_number, t.title, t.company_id, c.company_name, t.create_date, t.completed_date, (regexp_match(t.title, ' on ([A-Za-z0-9][A-Za-z0-9._-]+) at '))[1] AS device_hostname FROM tickets t LEFT JOIN companies c ON c.id = t.company_id INNER JOIN time_entries te ON te.ticket_id = t.id ${reanalyze ? '' : 'LEFT JOIN veeam_ticket_analysis vta ON vta.ticket_number = t.ticket_number'} WHERE t.title ILIKE 'Veeam:%' AND t.title NOT ILIKE '[Veeam RPO]%' AND t.create_date >= DATE_TRUNC('year', NOW()) ${reanalyze ? '' : 'AND vta.ticket_number IS NULL'} ORDER BY t.id, t.create_date DESC `); const tickets = ticketsRes.rows; if (tickets.length === 0) { return NextResponse.json({ started: false, message: 'All eligible tickets already analyzed', total: 0 }); } analysisState.isRunning = true; analysisState.total = tickets.length; analysisState.done = 0; analysisState.errors = 0; analysisState.startedAt = new Date(); runBatch(tickets).finally(() => { analysisState.isRunning = false; }); return NextResponse.json({ started: true, total: tickets.length }); }