/** * GET /api/dashboard/trends * Operational trend data backing /dashboard's chart row + queue posture. * * volumeByDay — last 30 days, ticket creation count per day * resolutionByDay — last 30 days, mean resolution hours per day completed * queueHeatmap — open tickets grouped by (queue, priority) * activeEngineers — top engineers today by hours logged * * All queries run in parallel. ~50 ms total against a warm DB. */ import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; const TREND_DAYS = 30; const TOP_QUEUES = 10; const TOP_ENGINEERS = 8; export async function GET() { const { error } = await requireAuth(); if (error) return error; const [volumeRes, resolutionRes, heatmapRes, engineersRes] = await Promise.all([ postgresClient.query<{ d: string; count: string }>( `WITH days AS ( SELECT generate_series( CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days', CURRENT_DATE, INTERVAL '1 day' )::date AS d ) SELECT d::text AS d, COALESCE(COUNT(t.id), 0)::text AS count FROM days LEFT JOIN tickets t ON t.create_date::date = days.d AND (t.is_deleted = false OR t.is_deleted IS NULL) GROUP BY d ORDER BY d`, ), postgresClient.query<{ d: string; avg_hours: string | null }>( `WITH days AS ( SELECT generate_series( CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days', CURRENT_DATE, INTERVAL '1 day' )::date AS d ) SELECT d::text AS d, AVG(EXTRACT(EPOCH FROM (t.completed_date - t.create_date)) / 3600.0)::text AS avg_hours FROM days LEFT JOIN tickets t ON t.completed_date::date = days.d AND t.create_date IS NOT NULL AND (t.is_deleted = false OR t.is_deleted IS NULL) GROUP BY d ORDER BY d`, ), postgresClient.query<{ queue_id: number | null; queue_label: string | null; priority: number | null; count: string; }>( `SELECT t.queue_id, q.label AS queue_label, t.priority, COUNT(*)::text AS count FROM tickets t LEFT JOIN queues q ON q.value = t.queue_id WHERE t.completed_date IS NULL AND (t.is_deleted = false OR t.is_deleted IS NULL) GROUP BY t.queue_id, q.label, t.priority ORDER BY COUNT(*) DESC`, ), postgresClient.query<{ resource_id: string; resource_name: string; hours: string; tickets_touched: string; }>( `SELECT te.resource_id::text, COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''), r.email, 'Resource ' || te.resource_id) AS resource_name, SUM(te.hours_worked)::text AS hours, COUNT(DISTINCT te.ticket_id)::text AS tickets_touched FROM time_entries te LEFT JOIN resources r ON r.id = te.resource_id WHERE te.entry_date::date = CURRENT_DATE AND te.hours_worked > 0 GROUP BY te.resource_id, r.first_name, r.last_name, r.email ORDER BY SUM(te.hours_worked) DESC LIMIT ${TOP_ENGINEERS}`, ), ]); // Heatmap: top N queues by open volume × priority columns const heatmapRows = heatmapRes.rows; const queueTotals = new Map(); for (const row of heatmapRows) { if (row.queue_id == null) continue; const t = queueTotals.get(row.queue_id) ?? { id: row.queue_id, label: row.queue_label ?? `Queue ${row.queue_id}`, total: 0, }; t.total += parseInt(row.count, 10); queueTotals.set(row.queue_id, t); } const topQueues = [...queueTotals.values()] .sort((a, b) => b.total - a.total) .slice(0, TOP_QUEUES); const heatmap = topQueues.map((q) => { const cells: Record = {}; for (const row of heatmapRows) { if (row.queue_id !== q.id || row.priority == null) continue; cells[row.priority] = (cells[row.priority] ?? 0) + parseInt(row.count, 10); } return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells }; }); return NextResponse.json({ volumeByDay: volumeRes.rows.map((r) => ({ date: r.d, count: parseInt(r.count, 10), })), resolutionByDay: resolutionRes.rows.map((r) => ({ date: r.d, avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10, })), queueHeatmap: heatmap, activeEngineers: engineersRes.rows.map((r) => ({ resourceId: r.resource_id, name: r.resource_name, hours: Math.round(parseFloat(r.hours) * 10) / 10, ticketsTouched: parseInt(r.tickets_touched, 10), })), }); }