import { pool, rowToTicket, loadConfig } from './db'; import type { Ticket } from './types'; // PM Insights: the problem/alert KPIs over the ACTIVE backlog, mirroring the // initial app's insights-button.js. Each group carries its full list plus the // "problematic" subset (what drives the alert counts and revenue-at-risk). const DAY = 86_400_000; function daysSince(v: string | null): number | null { if (!v) return null; const iso = v.includes('T') ? v : v.replace(' ', 'T'); const d = new Date(iso); return Number.isNaN(d.getTime()) ? null : (Date.now() - d.getTime()) / DAY; } function gbp(t: Ticket, fx: Record): number { if (t.finalCost == null || !(t.finalCost > 0)) return 0; const rate = fx[(t.currencyCode || 'GBP').toUpperCase()] ?? 1; return t.finalCost / rate; } function isColleague(name: string | null, colleagues: Set): boolean { return !!name && colleagues.has(name.trim().toLowerCase()); } function isAwaitingAgency(t: Ticket, colleagues: Set): boolean { if (!/progress/i.test(t.state)) return false; if (colleagues.size > 0) return !isColleague(t.lastActivityBy, colleagues); return !!t.lastActivityBy && t.lastActivityBy === t.requestedFor; } // Jira breached: linked, non-terminal Jira whose status has aged past its threshold. function jiraBreached(t: Ticket, thr: Record): boolean { const status = t.jira?.status; if (!status) return false; if (/closed|resolved|done|cancel|released|live/i.test(status)) return false; const age = daysSince(t.jira?.statusChangedAt ?? null); if (age == null) return false; const limit = /waiting.?po/i.test(status) ? (thr.waitingPo1 ?? 7) : /uat/i.test(status) ? (thr.jiraUAT ?? 7) : (thr.jiraStuck ?? 7); return age >= limit; } function clientOwed(t: Ticket, colleagues: Set): boolean { if (isColleague(t.lastActivityBy, colleagues)) return false; const age = daysSince(t.lastActivityAt); return age != null && age >= 1; } export interface InsightTicket { number: string; shortDesc: string; assignedTo: string | null; brand: string | null; market: string | null; state: string; days: number | null; link: string | null; jiraStatus: string | null; costGbp: number; } export interface AlertGroup { key: string; label: string; count: number; problematic: number; tickets: InsightTicket[]; } export interface PmRow { pm: string; tickets: number; revenue: number; alerts: number; revAtRisk: number; } export interface InsightsResponse { totalAlerts: number; revAtRisk: number; groups: AlertGroup[]; waitingPo: { count: number; revenue: number; levels: { l1: number; l2: number; l3: number } }; pms: PmRow[]; generatedAt: string; } async function fetchActive(): Promise { const { rows } = await pool.query('SELECT * FROM tickets WHERE status = $1', ['active']); return rows.map(rowToTicket); } function toInsightTicket(t: Ticket, days: number | null, fx: Record): InsightTicket { return { number: t.number, shortDesc: t.shortDesc, assignedTo: t.assignedTo, brand: t.brand, market: t.market, state: t.state, days: days != null ? Math.floor(days) : null, link: t.link, jiraStatus: t.jira?.status ?? null, costGbp: Math.round(gbp(t, fx)), }; } export async function getInsights(): Promise { const tickets = await fetchActive(); const cfg = loadConfig(); const thr = cfg.insightsThresholds ?? {}; const fx = cfg.fxRates ?? { GBP: 1 }; const colleagues = new Set((cfg.colleagues ?? []).map(c => c.trim().toLowerCase())); const problematic = new Set(); // ticket numbers flagged by any SN-alert group const groups: AlertGroup[] = []; const push = (key: string, label: string, members: Ticket[], isProb: (t: Ticket) => boolean, daysOf: (t: Ticket) => number | null, countProbTowardTotal = true) => { const list = members.map(t => ({ t, prob: isProb(t), days: daysOf(t) })); const probList = list.filter(x => x.prob); if (countProbTowardTotal) probList.forEach(x => problematic.add(x.t.number)); groups.push({ key, label, count: members.length, problematic: probList.length, tickets: list.sort((a, b) => (b.days ?? 0) - (a.days ?? 0)).map(x => toInsightTicket(x.t, x.days, fx)), }); }; const active = tickets; const lifetimeOf = (t: Ticket) => daysSince(t.openedAt); const inStateOf = (t: Ticket) => daysSince(t.stateChangedAt); const sinceTouch = (t: Ticket) => daysSince(t.lastActivityAt); push('unassigned', 'Unassigned', active.filter(t => !t.assignedTo), t => (lifetimeOf(t) ?? 0) >= (thr.unassigned ?? 1), lifetimeOf); push('assigned', 'Open / Assigned', active.filter(t => t.assignedTo && /open|new|assigned/i.test(t.state) && !/progress|hold|awaiting|closed/i.test(t.state)), t => (inStateOf(t) ?? 0) >= (thr.assigned ?? 2), inStateOf); push('hold', 'On Hold', active.filter(t => /on.?hold/i.test(t.state)), t => (inStateOf(t) ?? 0) >= (thr.hold ?? 5) || jiraBreached(t, thr) || clientOwed(t, colleagues), inStateOf); const wip = active.filter(t => /progress/i.test(t.state) && !isAwaitingAgency(t, colleagues)); push('wipStalled', 'WIP — Jira stalled', wip.filter(t => t.jira?.status), t => jiraBreached(t, thr), t => daysSince(t.jira?.statusChangedAt ?? null)); push('wipNoJira', 'WIP — no Jira link', wip.filter(t => !t.jira?.status), () => true, inStateOf); push('replied', 'Customer replied', active.filter(t => isAwaitingAgency(t, colleagues)), t => (sinceTouch(t) ?? 0) >= (thr.customerReplied ?? 1) || jiraBreached(t, thr), sinceTouch); push('awaiting', 'Awaiting customer info', active.filter(t => /awaiting/i.test(t.state)), t => (inStateOf(t) ?? 0) >= (thr.awaiting ?? 5) || jiraBreached(t, thr), inStateOf); // Informational groups (not counted toward Total Alerts / rev-at-risk) push('lifetime', 'Lifetime monsters (≥90d)', active.filter(t => (lifetimeOf(t) ?? 0) >= (thr.lifetime ?? 90)), () => true, lifetimeOf, false); push('inactive', 'Inactive requester', active.filter(t => /\(inactive\)/i.test(t.requestedFor ?? '')), () => true, lifetimeOf, false); // Waiting PO: Jira "Waiting PO" or a finance row with a blank PO (still delivered). const waiting = active.filter(t => /waiting.?po/i.test(t.jira?.status ?? '') || t.poNumber === ''); const poAge = (t: Ticket) => daysSince(t.stateChangedAt) ?? 0; const levels = { l1: 0, l2: 0, l3: 0 }; for (const t of waiting) { const a = poAge(t); if (a >= (thr.waitingPo3 ?? 18)) levels.l3++; else if (a >= (thr.waitingPo2 ?? 14)) levels.l2++; else if (a >= (thr.waitingPo1 ?? 7)) levels.l1++; } const waitingRevenue = Math.round(waiting.reduce((s, t) => s + gbp(t, fx), 0)); // Per-PM roll-up const pmMap = new Map(); for (const t of active) { const pm = t.assignedTo ?? 'Unassigned'; const row = pmMap.get(pm) ?? { pm, tickets: 0, revenue: 0, alerts: 0, revAtRisk: 0 }; row.tickets++; row.revenue += gbp(t, fx); if (problematic.has(t.number)) { row.alerts++; row.revAtRisk += gbp(t, fx); } pmMap.set(pm, row); } const pms = [...pmMap.values()] .map(r => ({ ...r, revenue: Math.round(r.revenue), revAtRisk: Math.round(r.revAtRisk) })) .sort((a, b) => b.alerts - a.alerts || b.revAtRisk - a.revAtRisk); const revAtRisk = Math.round([...problematic].reduce((s, num) => s + gbp(active.find(t => t.number === num)!, fx), 0)); return { totalAlerts: problematic.size, revAtRisk, groups, waitingPo: { count: waiting.length, revenue: waitingRevenue, levels }, pms, generatedAt: new Date().toISOString(), }; }