- 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
185 lines
8.3 KiB
TypeScript
185 lines
8.3 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
const MODEL = 'claude-sonnet-4-6';
|
|
const SYSTEM = 'You are a senior MSP consultant. Respond with valid JSON only — no markdown fences, no prose before or after the JSON object.';
|
|
|
|
export async function POST() {
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) {
|
|
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
|
|
}
|
|
|
|
try {
|
|
const [statsRes, categoryRes, resolutionRes, skillsRes, complexityRes, proceduresRes] =
|
|
await Promise.all([
|
|
postgresClient.query(`
|
|
SELECT
|
|
COUNT(*) AS total_analyzed,
|
|
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
|
|
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
|
|
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
|
|
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE backup_completed_before_tech = true)
|
|
/ NULLIF(COUNT(*) FILTER (WHERE backup_completed_before_tech IS NOT NULL), 0), 1) AS auto_resolved_pct,
|
|
SUM(hours_worked) AS total_hours
|
|
FROM veeam_ticket_analysis
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
`),
|
|
|
|
postgresClient.query(`
|
|
SELECT problem_category,
|
|
COUNT(*) AS count,
|
|
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
|
|
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
|
|
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
|
|
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
|
|
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct
|
|
FROM veeam_ticket_analysis
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
GROUP BY problem_category
|
|
ORDER BY count DESC
|
|
`),
|
|
|
|
postgresClient.query(`
|
|
SELECT resolution_type, COUNT(*) AS count
|
|
FROM veeam_ticket_analysis
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
GROUP BY resolution_type ORDER BY count DESC
|
|
`),
|
|
|
|
postgresClient.query(`
|
|
SELECT skill, COUNT(*) AS count
|
|
FROM veeam_ticket_analysis, unnest(skills_required) AS skill
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
GROUP BY skill ORDER BY count DESC LIMIT 20
|
|
`),
|
|
|
|
postgresClient.query(`
|
|
SELECT complexity, COUNT(*) AS count
|
|
FROM veeam_ticket_analysis
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
GROUP BY complexity
|
|
ORDER BY CASE complexity WHEN 'trivial' THEN 1 WHEN 'low' THEN 2 WHEN 'medium' THEN 3 WHEN 'high' THEN 4 ELSE 5 END
|
|
`),
|
|
|
|
// Sample recommended procedures per category (up to 3 per category)
|
|
postgresClient.query(`
|
|
SELECT problem_category, recommended_procedure
|
|
FROM (
|
|
SELECT problem_category, recommended_procedure,
|
|
ROW_NUMBER() OVER (PARTITION BY problem_category ORDER BY analyzed_at DESC) AS rn
|
|
FROM veeam_ticket_analysis
|
|
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
|
|
AND recommended_procedure IS NOT NULL AND recommended_procedure != ''
|
|
) ranked
|
|
WHERE rn <= 3
|
|
ORDER BY problem_category, rn
|
|
`),
|
|
]);
|
|
|
|
const stats = statsRes.rows[0];
|
|
const categories = categoryRes.rows;
|
|
const resolutions = resolutionRes.rows;
|
|
const skills = skillsRes.rows;
|
|
const complexity = complexityRes.rows;
|
|
const procedures = proceduresRes.rows;
|
|
|
|
if (parseInt(stats.total_analyzed) === 0) {
|
|
return NextResponse.json({ error: 'No analyzed tickets yet — run the analysis first.' }, { status: 400 });
|
|
}
|
|
|
|
const procByCategory: Record<string, string[]> = {};
|
|
for (const p of procedures) {
|
|
if (!procByCategory[p.problem_category]) procByCategory[p.problem_category] = [];
|
|
procByCategory[p.problem_category].push(p.recommended_procedure);
|
|
}
|
|
|
|
const prompt = `Review this YTD backup ticket data for a managed services provider and produce a practical operations summary.
|
|
|
|
## Dataset
|
|
- Tickets analyzed: ${stats.total_analyzed} (YTD, all had tech time logged)
|
|
- Total tech time: ${parseFloat(stats.total_hours ?? 0).toFixed(1)}h
|
|
- Avg hours per ticket: ${stats.avg_hours}h
|
|
- Same-day close rate: ${stats.same_day_pct}%
|
|
- Device was offline at alert time: ${stats.offline_pct}%
|
|
- Backup auto-completed before tech action: ${stats.auto_resolved_pct}%
|
|
- Assessed as preventable: ${stats.preventable_pct}%
|
|
|
|
## Problem Categories
|
|
${categories.map(c => `- ${c.problem_category}: ${c.count} tickets, avg ${c.avg_hours}h, ${c.same_day_pct}% same-day close, ${c.offline_pct ?? 0}% offline at time`).join('\n')}
|
|
|
|
## Resolution Types
|
|
${resolutions.map(r => `- ${r.resolution_type}: ${r.count} tickets`).join('\n')}
|
|
|
|
## Complexity Breakdown
|
|
${complexity.map(c => `- ${c.complexity}: ${c.count} tickets`).join('\n')}
|
|
|
|
## Top Skills Required (by frequency)
|
|
${skills.map(s => `- ${s.skill}: ${s.count} tickets`).join('\n')}
|
|
|
|
## Sample AI-Generated SOP Steps (per category)
|
|
${Object.entries(procByCategory).map(([cat, procs]) =>
|
|
`${cat}:\n${procs.map(p => ` • ${p}`).join('\n')}`
|
|
).join('\n')}
|
|
|
|
---
|
|
|
|
Return a JSON object with exactly this structure:
|
|
{
|
|
"headline": "one sentence executive summary",
|
|
"key_findings": ["3-4 bullet point findings with specific numbers"],
|
|
"issue_breakdown": [
|
|
{
|
|
"category": "exact machine key from Problem Categories above (e.g. device_offline, agent_issue)",
|
|
"insight": "1-2 sentence insight about this category",
|
|
"sop": "concrete, actionable SOP recommendation for handling this type"
|
|
}
|
|
],
|
|
"skills_assessment": "2-3 sentences on the skills picture — what's needed most, any gaps",
|
|
"quick_wins": ["2-3 specific things the MSP could do to reduce ticket volume or time spent"],
|
|
"automation_opportunities": "1-2 sentences on what could be automated or suppressed",
|
|
"training_priority": "one sentence on the highest-value training investment"
|
|
}
|
|
|
|
Be specific and direct. Reference actual numbers from the data. Avoid generic MSP advice.`;
|
|
|
|
const client = new Anthropic({ apiKey });
|
|
const message = await client.messages.create({
|
|
model: MODEL,
|
|
max_tokens: 4000,
|
|
system: SYSTEM,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
});
|
|
|
|
if (message.stop_reason === 'max_tokens') {
|
|
console.error('[VEEAM-SUMMARY] Response truncated at max_tokens');
|
|
return NextResponse.json({ error: 'Model response was truncated — try again' }, { status: 502 });
|
|
}
|
|
|
|
const raw = message.content[0].type === 'text' ? message.content[0].text : '{}';
|
|
|
|
const start = raw.indexOf('{');
|
|
const end = raw.lastIndexOf('}');
|
|
const json = start !== -1 && end > start ? raw.slice(start, end + 1) : raw;
|
|
|
|
let analysis: Record<string, any> = {};
|
|
try {
|
|
analysis = JSON.parse(json);
|
|
} catch (e) {
|
|
console.error('[VEEAM-SUMMARY] JSON parse failed. Raw response:', raw);
|
|
return NextResponse.json({ error: 'Model returned non-JSON response', raw }, { status: 502 });
|
|
}
|
|
|
|
return NextResponse.json({ analysis, model: MODEL, generated_at: new Date().toISOString() });
|
|
} catch (e: any) {
|
|
console.error('[VEEAM-SUMMARY] Unexpected error:', e);
|
|
return NextResponse.json({ error: e.message ?? 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|