Files
forge/server/insights.ts
T

176 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-08-29 12:30:19 +03:00
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<string, number>): 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<string>): boolean {
return !!name && colleagues.has(name.trim().toLowerCase());
}
function isAwaitingAgency(t: Ticket, colleagues: Set<string>): 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<string, number>): 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<string>): 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<Ticket[]> {
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<string, number>): 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<InsightsResponse> {
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<string>(); // 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<string, PmRow>();
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(),
};
}