wulf-pulse/app/api/veeam/ticket-analysis/run/route.ts
lorentz ea3471d38d feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison)
- Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis
- Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison)
- Add veeam-analysis-state.ts and rmm-device-resolver.ts services
- Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis
- Add backup-status page updates and nav links for new Veeam pages
- Add scripts: deactivate-cis-for-inactive-companies, workstation category updates
- Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt
- Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
2026-04-29 09:16:46 -04:00

230 lines
9 KiB
TypeScript

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<void> {
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<string, any> = {};
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<void> {
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 });
}