From 24e20c7ae7d9ae47df906729f299b59e3c8ba8ac Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 16:49:49 -0400 Subject: [PATCH] feat(03-01): rewrite /api/mobile/dashboard to return kpis/needsAttention/workers shape - Exports MobileDashboardResponse, KpiResponse, AttentionResponse, WorkerResponse interfaces - Single Promise.all with 6 parameterless queries (KPI, failed backups, stalled workflows, analyzer, RMM, backup success) - Ticket KPIs exclude out-of-scope companies via company_scope filter - SLA breaches tone='attention' when value > 0 - Worker status rules: down if fail_1h>0 and in_flight=0, warn if fail_1h>0, otherwise ok - Backup status: ok >= 95%, warn >= 80%, down otherwise --- app/api/mobile/dashboard/route.ts | 307 ++++++++++++++++++++---------- 1 file changed, 203 insertions(+), 104 deletions(-) diff --git a/app/api/mobile/dashboard/route.ts b/app/api/mobile/dashboard/route.ts index f28a86d..e582f8a 100644 --- a/app/api/mobile/dashboard/route.ts +++ b/app/api/mobile/dashboard/route.ts @@ -1,115 +1,214 @@ +/** + * GET /api/mobile/dashboard + * Single round-trip returning the three sections consumed by the new mobile + * dashboard layout: 4 KPIs, 3 Needs Attention items, 3 worker/backup status + * entries. + * + * All ticket counts exclude out-of-scope companies (company_scope filter, + * same idiom as /api/dashboard/overview). + */ + import { NextResponse } from 'next/server'; -import { postgresClient } from '@/lib/services/postgres-client'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; -async function getMobileClassFilter(): Promise { - try { - const result = await postgresClient.query( - `SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')` - ); - const map: Record = {}; - result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; }); +// ─── Response shape ────────────────────────────────────────────────────────── - const catIds = (map['mobile_company_category_ids'] || '1') - .split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); - const exclIds = (map['mobile_excluded_company_ids'] || '') - .split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); - - const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true'; - const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : ''; - return [catCond, exclCond].filter(Boolean).join(' AND '); - } catch (error) { - console.error('Error fetching mobile company filter:', error); - return 'c.company_category_id = 1'; - } +export interface KpiResponse { + id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches'; + label: string; + value: number; + caption?: string; + tone?: 'default' | 'attention'; } +export interface AttentionResponse { + id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows'; + label: string; + count: number; + href: string; +} + +export interface WorkerResponse { + id: 'analyzer' | 'rmm' | 'backup_success_rate'; + label: string; + value: string; + status: 'ok' | 'warn' | 'down'; + href: string; +} + +export interface MobileDashboardResponse { + kpis: KpiResponse[]; + needsAttention: AttentionResponse[]; + workers: WorkerResponse[]; +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + export async function GET() { - const classFilter = await getMobileClassFilter(); + const { error } = await requireAuth(); + if (error) return error; - const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([ - postgresClient.query(` - SELECT t.status, COUNT(*) as count - FROM tickets t - INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} - WHERE t.status != 5 AND t.is_deleted = false - GROUP BY t.status ORDER BY count DESC - `), - postgresClient.query(` - SELECT t.queue_id, q.label as queue_label, COUNT(*) as count - FROM tickets t - INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} - LEFT JOIN queues q ON q.value = t.queue_id - WHERE t.status != 5 AND t.is_deleted = false - GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 8 - `), - postgresClient.query(` - SELECT t.priority, p.label, COUNT(*) as count - FROM tickets t - INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} - LEFT JOIN priorities p ON p.value = t.priority - WHERE t.status != 5 AND t.is_deleted = false - GROUP BY t.priority, p.label ORDER BY count DESC - `), - postgresClient.query(` - SELECT t.id, t.ticket_number, t.title, t.status, t.priority, - t.last_activity_date, t.company_id, - c.company_name, q.label as queue_label, - r.first_name || ' ' || r.last_name as assigned_to - FROM tickets t - INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} - LEFT JOIN queues q ON q.value = t.queue_id - LEFT JOIN resources r ON r.id = t.assigned_resource_id - WHERE t.status != 5 AND t.is_deleted = false AND t.last_activity_date IS NOT NULL - ORDER BY t.last_activity_date DESC LIMIT 10 - `), - postgresClient.query(` - SELECT - COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL - AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as resp_met, - COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL) as resp_total, - COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL - AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met, - COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL) as res_total - FROM tickets t - INNER JOIN companies c ON c.id = t.company_id AND ${classFilter} - WHERE t.create_date >= NOW() - INTERVAL '30 days' AND t.is_deleted = false - `), - ]); + try { + const [ + kpiRes, + failedBackupsRes, + stalledWorkflowsRes, + analyzerRes, + rmmRes, + backupSuccessRes, + ] = await Promise.all([ + /* 1. KPI snapshot — four counts in one row, scoped companies excluded */ + postgresClient.query<{ + open_total: string; + opened_today: string; + resolved_today: string; + sla_breaches: string; + }>(` + SELECT + COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total, + COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today, + COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today, + COUNT(*) FILTER ( + WHERE completed_date IS NULL + AND due_date_time IS NOT NULL + AND due_date_time < NOW() + )::text AS sla_breaches + FROM tickets + WHERE (is_deleted = false OR is_deleted IS NULL) + AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false) + `), - const statusLabels: Record = { - 1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress', - 9: 'Scheduled', 12: 'On Hold', 14: 'Waiting Customer', - 19: 'Waiting Materials', 21: 'Dispatched', 25: 'In Review', - 27: 'Pending Decision', 30: 'On Hold', 45: 'Escalated', 47: 'Waiting Customer', - 56: 'Waiting Vendor', 58: 'Waiting Parts', 59: 'Pending Approval', - 60: 'In Deployment', 66: 'Closed', 68: 'Resolved', 70: 'Customer Follow-Up', 71: 'Archived', - }; + /* 2. Failed backups in the last 24 hours (Needs Attention) */ + postgresClient.query<{ count: string }>(` + SELECT COUNT(*)::text AS count FROM ( + SELECT 1 FROM veeam_backup_jobs + WHERE last_run >= NOW() - INTERVAL '24 hours' + AND is_enabled = true AND status = 'Failed' + UNION ALL + SELECT 1 FROM veeam_backup_agent_jobs + WHERE last_run >= NOW() - INTERVAL '24 hours' + AND is_enabled = true AND status = 'Failed' + ) f + `), - const slaRow = sla.rows[0]; + /* 3. Stalled workflow executions — pending > 5 minutes (Needs Attention) */ + postgresClient.query<{ count: string }>(` + SELECT COUNT(*)::text AS count + FROM workflow_executions + WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes' + `), - return NextResponse.json({ - open_total: byStatus.rows.reduce((s, r) => s + parseInt(r.count), 0), - by_status: byStatus.rows.map(r => ({ - status: parseInt(r.status), - label: statusLabels[r.status] ?? `Status ${r.status}`, - count: parseInt(r.count), - })), - by_queue: byQueue.rows.map(r => ({ - queue_id: r.queue_id, - label: r.queue_label ?? `Queue ${r.queue_id}`, - count: parseInt(r.count), - })), - by_priority: byPriority.rows.map(r => ({ - priority: parseInt(r.priority), - label: r.label ?? `P${r.priority}`, - count: parseInt(r.count), - })), - recent: recentActivity.rows, - sla: { - response_met: parseInt(slaRow.resp_met ?? 0), - response_total: parseInt(slaRow.resp_total ?? 0), - resolution_met: parseInt(slaRow.res_met ?? 0), - resolution_total: parseInt(slaRow.res_total ?? 0), - }, - }); + /* 4. Analyzer worker — in-flight + recent failures */ + postgresClient.query<{ in_flight: string; fail_1h: string }>(` + SELECT + COUNT(*) FILTER ( + WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review') + )::text AS in_flight, + COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h + FROM analyzer_jobs + `), + + /* 5. RMM worker — in-flight + recent failures */ + postgresClient.query<{ in_flight: string; fail_1h: string }>(` + SELECT + COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight, + COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h + FROM rmm_executions + `), + + /* 6. Backup success rate (24h) — same calculation as /api/veeam/backup-status */ + postgresClient.query<{ success: string; total: string }>(` + SELECT + COUNT(*) FILTER (WHERE status = 'Success')::text AS success, + COUNT(*)::text AS total + FROM ( + SELECT status FROM veeam_backup_jobs + WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true + UNION ALL + SELECT status FROM veeam_backup_agent_jobs + WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true + ) j + `), + ]); + + // ── Build KPIs ──────────────────────────────────────────────────────────── + const kpiRow = kpiRes.rows[0]; + const openTotal = parseInt(kpiRow?.open_total ?? '0', 10); + const openedToday = parseInt(kpiRow?.opened_today ?? '0', 10); + const resolvedToday = parseInt(kpiRow?.resolved_today ?? '0', 10); + const slaBreaches = parseInt(kpiRow?.sla_breaches ?? '0', 10); + + const kpis: KpiResponse[] = [ + { id: 'open_total', label: 'Open total', value: openTotal, tone: 'default' }, + { id: 'opened_today', label: 'Opened today', value: openedToday, tone: 'default' }, + { id: 'resolved_today', label: 'Resolved today', value: resolvedToday, tone: 'default' }, + { id: 'sla_breaches', label: 'SLA breaches', value: slaBreaches, tone: slaBreaches > 0 ? 'attention' : 'default' }, + ]; + + // ── Build Needs Attention ───────────────────────────────────────────────── + const failedBackups = parseInt(failedBackupsRes.rows[0]?.count ?? '0', 10); + const stalledWorkflows = parseInt(stalledWorkflowsRes.rows[0]?.count ?? '0', 10); + + const needsAttention: AttentionResponse[] = [ + { id: 'overdue_tickets', label: 'Overdue tickets', count: slaBreaches, href: '/tickets?overdue=true' }, + { id: 'failed_backups', label: 'Failed backups (24h)', count: failedBackups, href: '/backup-status' }, + { id: 'stalled_workflows', label: 'Stalled workflows', count: stalledWorkflows, href: '/admin/workflow' }, + ]; + + // ── Build Workers ───────────────────────────────────────────────────────── + const aRow = analyzerRes.rows[0]; + const analyzerInFlight = parseInt(aRow?.in_flight ?? '0', 10); + const analyzerFail1h = parseInt(aRow?.fail_1h ?? '0', 10); + let analyzerStatus: 'ok' | 'warn' | 'down' = 'ok'; + if (analyzerFail1h > 0 && analyzerInFlight === 0) analyzerStatus = 'down'; + else if (analyzerFail1h > 0) analyzerStatus = 'warn'; + + const rRow = rmmRes.rows[0]; + const rmmInFlight = parseInt(rRow?.in_flight ?? '0', 10); + const rmmFail1h = parseInt(rRow?.fail_1h ?? '0', 10); + let rmmStatus: 'ok' | 'warn' | 'down' = 'ok'; + if (rmmFail1h > 0 && rmmInFlight === 0) rmmStatus = 'down'; + else if (rmmFail1h > 0) rmmStatus = 'warn'; + + const bRow = backupSuccessRes.rows[0]; + const bSuccess = parseInt(bRow?.success ?? '0', 10); + const bTotal = parseInt(bRow?.total ?? '0', 10); + const backupPct = bTotal > 0 ? Math.round((bSuccess / bTotal) * 1000) / 10 : 100; + let backupStatus: 'ok' | 'warn' | 'down' = 'ok'; + if (backupPct < 80) backupStatus = 'down'; + else if (backupPct < 95) backupStatus = 'warn'; + + const workers: WorkerResponse[] = [ + { + id: 'analyzer', + label: 'Analyzer', + value: `${analyzerInFlight} in flight`, + status: analyzerStatus, + href: '/admin/analytics', + }, + { + id: 'rmm', + label: 'RMM Overshell', + value: `${rmmInFlight} in flight`, + status: rmmStatus, + href: '/admin/rmm-overshell', + }, + { + id: 'backup_success_rate', + label: 'Backup success (24h)', + value: `${backupPct}%`, + status: backupStatus, + href: '/backup-status', + }, + ]; + + return NextResponse.json({ kpis, needsAttention, workers }); + } catch (e) { + console.error('[/api/mobile/dashboard] failed:', e); + return NextResponse.json( + { error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' }, + { status: 500 }, + ); + } }