- 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
230 lines
9 KiB
TypeScript
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 });
|
|
}
|