Initial import of Forge app
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import { pool, loadConfig } from './db';
|
||||
|
||||
// The analytics engine. Fetches the ticket rows once and computes the Overall-tab
|
||||
// aggregations and the PM×size SLA heatmaps in memory (966 rows — trivial). Mirrors
|
||||
// the initial app's `analytics_data` derivations. All money is converted to the
|
||||
// display currency (GBP) via the fixed FX rates (units per 1 GBP).
|
||||
|
||||
interface Row {
|
||||
number: string; status: string; state: string; assigned_to: string | null;
|
||||
brand: string | null; market: string | null; business_unit: string | null;
|
||||
requested_for: string | null; opened_by: string | null;
|
||||
opened_date: Date | string | null; closed_date: Date | string | null;
|
||||
opened_at: Date | string | null;
|
||||
final_cost: string | number | null; currency_code: string | null; size: string | null;
|
||||
ttfr_minutes: number | null; client_resp_minutes: number | null;
|
||||
fulfillment_date: Date | string | null; first_assigned_date: Date | string | null;
|
||||
to_do_at: Date | string | null; in_uat_at: Date | string | null;
|
||||
ticket_year: number | null;
|
||||
}
|
||||
|
||||
const SIZES = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
|
||||
|
||||
function ymd(v: Date | string | null): string | null {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Date) {
|
||||
return `${v.getFullYear()}-${String(v.getMonth() + 1).padStart(2, '0')}-${String(v.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
const s = String(v);
|
||||
return s.length >= 10 ? s.slice(0, 10) : null;
|
||||
}
|
||||
function month(v: Date | string | null): string | null {
|
||||
const d = ymd(v);
|
||||
return d ? d.slice(0, 7) : null;
|
||||
}
|
||||
function toDate(v: Date | string | null): Date | null {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Date) return v;
|
||||
const s = String(v).includes('T') ? String(v) : String(v).replace(' ', 'T');
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function daysBetween(a: Date | string | null, b: Date | string | null): number | null {
|
||||
const da = toDate(a), db = toDate(b);
|
||||
if (!da || !db) return null;
|
||||
return (db.getTime() - da.getTime()) / 86_400_000;
|
||||
}
|
||||
|
||||
async function fetchRows(): Promise<Row[]> {
|
||||
const { rows } = await pool.query<Row>(`
|
||||
SELECT number, status, state, assigned_to, brand, market, business_unit,
|
||||
requested_for, opened_by, opened_date, closed_date, opened_at,
|
||||
final_cost, currency_code, size, ttfr_minutes, client_resp_minutes,
|
||||
fulfillment_date, first_assigned_date, to_do_at, in_uat_at, ticket_year
|
||||
FROM tickets
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toGBP(cost: number | null, ccy: string | null, fx: Record<string, number>): number | null {
|
||||
if (cost == null || !(cost > 0)) return null;
|
||||
const rate = fx[(ccy || 'GBP').toUpperCase()] ?? 1;
|
||||
return cost / rate;
|
||||
}
|
||||
|
||||
function countBy<T>(items: T[], key: (t: T) => string | null): { key: string; label: string; count: number }[] {
|
||||
const m = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
const k = key(it);
|
||||
if (!k) continue;
|
||||
m.set(k, (m.get(k) ?? 0) + 1);
|
||||
}
|
||||
return [...m.entries()].map(([k, count]) => ({ key: k, label: k, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export interface OverviewResponse {
|
||||
totals: { total: number; active: number; closed: number };
|
||||
openedByMonth: { month: string; count: number }[];
|
||||
closedByMonth: { month: string; count: number }[];
|
||||
revenueByMonth: { month: string; count: number }[]; // count = GBP revenue
|
||||
byState: { key: string; label: string; count: number }[];
|
||||
byBrand: NestedBucket[];
|
||||
byMarket: { key: string; label: string; count: number }[];
|
||||
byBusinessUnit: { key: string; label: string; count: number }[];
|
||||
byRequester: NestedBucket[];
|
||||
byRequesterShare: { key: string; label: string; count: number }[]; // full distribution for the donut
|
||||
lifetime: { buckets: { key: string; label: string; count: number }[]; medianDays: number | null; avgDays: number | null; closed: number };
|
||||
}
|
||||
|
||||
interface NestedBucket { key: string; label: string; count: number; children: { key: string; label: string; count: number }[]; }
|
||||
|
||||
// Two-level breakdown: parent dimension → child dimension counts.
|
||||
function nestedCountBy(items: Row[], parent: (r: Row) => string | null, child: (r: Row) => string | null, limit = 20): NestedBucket[] {
|
||||
const m = new Map<string, { count: number; kids: Map<string, number> }>();
|
||||
for (const r of items) {
|
||||
const p = parent(r);
|
||||
if (!p) continue;
|
||||
const entry = m.get(p) ?? { count: 0, kids: new Map() };
|
||||
entry.count++;
|
||||
const c = child(r);
|
||||
if (c) entry.kids.set(c, (entry.kids.get(c) ?? 0) + 1);
|
||||
m.set(p, entry);
|
||||
}
|
||||
return [...m.entries()]
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.slice(0, limit)
|
||||
.map(([key, v]) => ({
|
||||
key, label: key, count: v.count,
|
||||
children: [...v.kids.entries()].sort((a, b) => b[1] - a[1]).map(([k, count]) => ({ key: k, label: k, count })),
|
||||
}));
|
||||
}
|
||||
|
||||
function bySeriesMonth(items: Row[], dateOf: (r: Row) => Date | string | null): { month: string; count: number }[] {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of items) {
|
||||
const mo = month(dateOf(r));
|
||||
if (mo) m.set(mo, (m.get(mo) ?? 0) + 1);
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, count]) => ({ month, count }));
|
||||
}
|
||||
|
||||
export async function getOverview(): Promise<OverviewResponse> {
|
||||
const rows = await fetchRows();
|
||||
const cfg = loadConfig();
|
||||
const fx = cfg.fxRates;
|
||||
const closed = rows.filter(r => r.status === 'closed');
|
||||
const active = rows.filter(r => r.status === 'active');
|
||||
|
||||
// Revenue by close-month (display currency)
|
||||
const revMap = new Map<string, number>();
|
||||
for (const r of closed) {
|
||||
const mo = month(r.closed_date);
|
||||
const gbp = toGBP(r.final_cost != null ? Number(r.final_cost) : null, r.currency_code, fx);
|
||||
if (mo && gbp) revMap.set(mo, (revMap.get(mo) ?? 0) + gbp);
|
||||
}
|
||||
const revenueByMonth = [...revMap.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([month, v]) => ({ month, count: Math.round(v) }));
|
||||
|
||||
// Lifetime at close (days) → 6 buckets + median/avg
|
||||
const lifetimes: number[] = [];
|
||||
for (const r of closed) {
|
||||
const d = daysBetween(r.opened_date ?? r.opened_at, r.closed_date);
|
||||
if (d != null && d >= 0) lifetimes.push(d);
|
||||
}
|
||||
const LB = [
|
||||
{ key: '<7d', label: '< 7 days', hi: 7 },
|
||||
{ key: '7-14d', label: '7–14 days', hi: 14 },
|
||||
{ key: '14-31d', label: '14–31 days', hi: 31 },
|
||||
{ key: '1-3m', label: '1–3 months', hi: 93 },
|
||||
{ key: '3-6m', label: '3–6 months', hi: 186 },
|
||||
{ key: '>6m', label: '> 6 months', hi: Infinity },
|
||||
];
|
||||
const buckets = LB.map(b => ({ key: b.key, label: b.label, count: 0 }));
|
||||
for (const d of lifetimes) {
|
||||
const i = LB.findIndex(b => d < b.hi);
|
||||
buckets[i === -1 ? LB.length - 1 : i].count++;
|
||||
}
|
||||
const sorted = [...lifetimes].sort((a, b) => a - b);
|
||||
const medianDays = sorted.length ? Math.round(sorted[Math.floor(sorted.length / 2)]) : null;
|
||||
const avgDays = sorted.length ? Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length) : null;
|
||||
|
||||
return {
|
||||
totals: { total: rows.length, active: active.length, closed: closed.length },
|
||||
openedByMonth: bySeriesMonth(rows, r => r.opened_date ?? r.opened_at),
|
||||
closedByMonth: bySeriesMonth(closed, r => r.closed_date),
|
||||
revenueByMonth,
|
||||
byState: countBy(active, r => r.state || '—'),
|
||||
byBrand: nestedCountBy(rows, r => r.brand, r => r.market),
|
||||
byMarket: countBy(rows, r => r.market).slice(0, 20),
|
||||
byBusinessUnit: countBy(rows, r => normalizeBU(r.business_unit)),
|
||||
byRequester: nestedCountBy(rows, r => r.requested_for ?? r.opened_by, r => r.brand),
|
||||
byRequesterShare: countBy(rows, r => r.requested_for ?? r.opened_by),
|
||||
lifetime: { buckets, medianDays, avgDays, closed: lifetimes.length },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBU(bu: string | null): string | null {
|
||||
if (!bu) return null;
|
||||
const t = bu.trim();
|
||||
if (!t) return null;
|
||||
// Fix the casing dupes flagged in the data (e.g. "hygiene" → "Hygiene").
|
||||
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||
}
|
||||
|
||||
// --- SLA heatmaps (PM × size) ----------------------------------------------
|
||||
export interface SlaCell { avgDays: number | null; count: number; onTime: number; onTimePct: number | null; }
|
||||
export interface SlaMetric {
|
||||
key: string; title: string; unit: 'days' | 'hours';
|
||||
norms: Record<string, number>; // per size, in the metric's unit
|
||||
pms: string[]; // row order
|
||||
sizes: string[]; // col order (XS..XXL)
|
||||
grid: Record<string, Record<string, SlaCell>>; // grid[pm][size]
|
||||
totals: Record<string, SlaCell>; // per-PM total across sizes
|
||||
}
|
||||
|
||||
type MetricDef = {
|
||||
key: string; title: string; unit: 'days' | 'hours'; normKey: string;
|
||||
value: (r: Row) => number | null; // in DAYS
|
||||
scope: (r: Row) => boolean;
|
||||
};
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{ key: 'ttfr', title: 'Average Time to First Reply', unit: 'hours', normKey: 'ttfr',
|
||||
value: r => r.ttfr_minutes != null ? r.ttfr_minutes / 1440 : null, scope: r => r.ttfr_minutes != null },
|
||||
{ key: 'cresp', title: 'Average PM Response Time', unit: 'hours', normKey: 'cresp',
|
||||
value: r => r.client_resp_minutes != null ? r.client_resp_minutes / 1440 : null, scope: r => r.client_resp_minutes != null },
|
||||
{ key: 'avgclose', title: 'Average Time to Close a Project', unit: 'days', normKey: 'avgdays',
|
||||
value: r => daysBetween(r.opened_date ?? r.opened_at, r.closed_date), scope: r => r.status === 'closed' },
|
||||
{ key: 'otd', title: 'Projects Delivered on Time', unit: 'days', normKey: 'otd',
|
||||
value: r => daysBetween(r.opened_date ?? r.opened_at, r.closed_date), scope: r => r.status === 'closed' },
|
||||
{ key: 'assign', title: 'Time to Assign a PM', unit: 'days', normKey: 'asla',
|
||||
value: r => { const d = daysBetween(r.fulfillment_date, r.first_assigned_date); return d == null ? null : Math.max(0, d); }, scope: () => true },
|
||||
{ key: 'preview', title: 'Time to Send Preview Link', unit: 'days', normKey: 'psla',
|
||||
value: r => { const d = daysBetween(r.to_do_at, r.in_uat_at); return d != null && d >= 0 ? d : null; }, scope: () => true },
|
||||
];
|
||||
|
||||
function normDays(norm: number, unit: 'days' | 'hours'): number {
|
||||
return unit === 'hours' ? norm / 24 : norm;
|
||||
}
|
||||
|
||||
export async function getSlaHeatmaps(): Promise<SlaMetric[]> {
|
||||
const rows = await fetchRows();
|
||||
const cfg = loadConfig();
|
||||
const hidden = new Set<string>(); // future: pm_kpi_settings.hidden
|
||||
const pms = [...new Set(rows.map(r => r.assigned_to).filter((p): p is string => !!p && !hidden.has(p)))].sort();
|
||||
|
||||
return METRICS.map(def => {
|
||||
const norms = (cfg.norms?.[def.normKey] ?? {}) as Record<string, number>;
|
||||
const grid: Record<string, Record<string, SlaCell>> = {};
|
||||
const totals: Record<string, SlaCell> = {};
|
||||
|
||||
for (const pm of pms) {
|
||||
grid[pm] = {};
|
||||
const pmRows = rows.filter(r => r.assigned_to === pm && def.scope(r));
|
||||
let tSum = 0, tN = 0, tOnTime = 0, tScored = 0;
|
||||
for (const size of SIZES) {
|
||||
const cellRows = pmRows.filter(r => r.size === size);
|
||||
const vals = cellRows.map(def.value).filter((v): v is number => v != null && v >= 0);
|
||||
const cell = cellFor(vals, norms[size], def.unit);
|
||||
grid[pm][size] = cell;
|
||||
tSum += vals.reduce((a, b) => a + b, 0); tN += vals.length;
|
||||
if (norms[size] != null) { tOnTime += cell.onTime; tScored += vals.length; }
|
||||
}
|
||||
totals[pm] = {
|
||||
avgDays: tN ? round2(tSum / tN) : null, count: tN,
|
||||
onTime: tOnTime, onTimePct: tScored ? Math.round((tOnTime * 100) / tScored) : null,
|
||||
};
|
||||
}
|
||||
return { key: def.key, title: def.title, unit: def.unit, norms, pms, sizes: SIZES, grid, totals };
|
||||
});
|
||||
}
|
||||
|
||||
function cellFor(valsDays: number[], normUnit: number | undefined, unit: 'days' | 'hours'): SlaCell {
|
||||
if (!valsDays.length) return { avgDays: null, count: 0, onTime: 0, onTimePct: null };
|
||||
const avg = valsDays.reduce((a, b) => a + b, 0) / valsDays.length;
|
||||
let onTime = 0, pct: number | null = null;
|
||||
if (normUnit != null) {
|
||||
const nd = normDays(normUnit, unit);
|
||||
onTime = valsDays.filter(v => v <= nd).length;
|
||||
pct = Math.round((onTime * 100) / valsDays.length);
|
||||
}
|
||||
return { avgDays: round2(avg), count: valsDays.length, onTime, onTimePct: pct };
|
||||
}
|
||||
function round2(n: number): number { return Math.round(n * 100) / 100; }
|
||||
|
||||
// Chart #17 — average time each Jira status is held, across all tickets that
|
||||
// carry per-status durations (ms), rendered in the board's workflow column order.
|
||||
// Statuses with < 2 tickets are dropped (matches the initial app).
|
||||
export interface JiraDuration { status: string; avgDays: number; avgHours: number; count: number; }
|
||||
export async function getJiraDurations(): Promise<{ order: string[]; rows: JiraDuration[] }> {
|
||||
const cfg = loadConfig();
|
||||
const order = cfg.jiraColumns ?? [];
|
||||
const { rows } = await pool.query<{ jira: { statusDurations?: Record<string, number> } | null }>(
|
||||
`SELECT jira FROM tickets WHERE jira ? 'statusDurations'`,
|
||||
);
|
||||
const agg = new Map<string, { sum: number; n: number }>();
|
||||
for (const r of rows) {
|
||||
const sd = r.jira?.statusDurations;
|
||||
if (!sd) continue;
|
||||
for (const [status, ms] of Object.entries(sd)) {
|
||||
const v = Number(ms);
|
||||
if (!Number.isFinite(v) || v <= 0) continue;
|
||||
const a = agg.get(status) ?? { sum: 0, n: 0 };
|
||||
a.sum += v; a.n += 1; agg.set(status, a);
|
||||
}
|
||||
}
|
||||
const result: JiraDuration[] = [...agg.entries()]
|
||||
.filter(([, a]) => a.n >= 2)
|
||||
.map(([status, a]) => ({
|
||||
status,
|
||||
avgDays: round2(a.sum / a.n / 86_400_000),
|
||||
avgHours: round2(a.sum / a.n / 3_600_000),
|
||||
count: a.n,
|
||||
}))
|
||||
.sort((x, y) => {
|
||||
const ix = order.indexOf(x.status), iy = order.indexOf(y.status);
|
||||
if (ix !== -1 && iy !== -1) return ix - iy;
|
||||
if (ix !== -1) return -1;
|
||||
if (iy !== -1) return 1;
|
||||
return y.avgDays - x.avgDays;
|
||||
});
|
||||
return { order, rows: result };
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<Record<string, unknown>> {
|
||||
const { rows } = await pool.query<{ key: string; value: unknown }>('SELECT key, value FROM app_config');
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const r of rows) out[r.key] = r.value;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import session from 'express-session';
|
||||
import connectPgSimple from 'connect-pg-simple';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { pool, ROLES, type Role } from './db';
|
||||
|
||||
// Session-cookie auth for the read UI (username + password), mirroring the Husky
|
||||
// template. Sessions are stored in Postgres (connect-pg-simple) so they survive
|
||||
// restarts and don't leak like MemoryStore. The sync ingest (/api/sync) uses its
|
||||
// own bearer token and is unaffected.
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user?: { username: string; role: Role };
|
||||
}
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export function sessionMiddleware(): RequestHandler {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
if (isProd) {
|
||||
console.error('FATAL: SESSION_SECRET is required in production');
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn('SESSION_SECRET unset — using an insecure dev fallback');
|
||||
}
|
||||
const PgStore = connectPgSimple(session);
|
||||
return session({
|
||||
store: new PgStore({ pool, tableName: 'user_sessions', createTableIfMissing: true }),
|
||||
secret: secret || 'dev-insecure-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: 'forge.sid',
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd, // requires HTTPS in prod (behind the reverse proxy)
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Gate for the read API. 401 (not 403) so the client drops to the login screen.
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session?.user) return next();
|
||||
res.status(401).json({ error: 'unauthenticated' });
|
||||
}
|
||||
|
||||
// Gate requiring at least `min` role. 401 when unauthenticated (client → login),
|
||||
// 403 when authenticated but under-privileged (permission error, stays put).
|
||||
export function requireRole(min: Role): (req: Request, res: Response, next: NextFunction) => void {
|
||||
const minRank = ROLES.indexOf(min);
|
||||
return (req, res, next) => {
|
||||
const user = req.session?.user;
|
||||
if (!user) return void res.status(401).json({ error: 'unauthenticated' });
|
||||
if (ROLES.indexOf(user.role) < minRank) return void res.status(403).json({ error: 'forbidden' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Verify a username/password against app_users. Returns {username, role} on
|
||||
// success, null on any failure. Constant-time via bcrypt.compare; never logs the password.
|
||||
export async function verifyLogin(username: string, password: string): Promise<{ username: string; role: Role } | null> {
|
||||
if (!username || !password) return null;
|
||||
try {
|
||||
const { rows } = await pool.query<{ username: string; password_hash: string; role: Role }>(
|
||||
'SELECT username, password_hash, role FROM app_users WHERE username = $1',
|
||||
[username],
|
||||
);
|
||||
const row = rows[0];
|
||||
// Compare against a valid (never-matching) hash when the user is unknown, so
|
||||
// the bcrypt work runs either way and login timing doesn't reveal whether the
|
||||
// username exists.
|
||||
const hash = row?.password_hash ?? '$2b$12$D.FzBg5Tc02/Jpq7efzno.0TATE/sQKIX1w4yGFz0K5qI5FRp3Qdu';
|
||||
const ok = await bcrypt.compare(password, hash);
|
||||
if (!ok || !row) return null;
|
||||
pool.query('UPDATE app_users SET last_login_at = NOW() WHERE username = $1', [row.username])
|
||||
.catch(err => console.error('last_login_at update failed:', (err as Error).message));
|
||||
return { username: row.username, role: row.role };
|
||||
} catch (err) {
|
||||
console.error('verifyLogin error:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"insightsThresholds": {
|
||||
"assigned": 2,
|
||||
"awaiting": 5,
|
||||
"customerReplied": 1,
|
||||
"hold": 5,
|
||||
"jiraStuck": 7,
|
||||
"jiraUAT": 7,
|
||||
"lifetime": 90,
|
||||
"noChase": 5,
|
||||
"unassigned": 1,
|
||||
"waitingPo1": 7,
|
||||
"waitingPo2": 14,
|
||||
"waitingPo3": 18,
|
||||
"wip": 7
|
||||
},
|
||||
"fxRates": {
|
||||
"EUR": 1.2,
|
||||
"GBP": 1,
|
||||
"MXN": 20
|
||||
},
|
||||
"sizeThresholds": {
|
||||
"XS": 120,
|
||||
"S": 300,
|
||||
"M": 600,
|
||||
"L": 1200,
|
||||
"XL": 3000,
|
||||
"XXL": 9000
|
||||
},
|
||||
"norms": {
|
||||
"otd": {
|
||||
"XS": 7,
|
||||
"S": 14,
|
||||
"M": 30,
|
||||
"L": 90,
|
||||
"XL": 90,
|
||||
"XXL": 90
|
||||
},
|
||||
"avgdays": {
|
||||
"XS": 7,
|
||||
"S": 14,
|
||||
"M": 30,
|
||||
"L": 90,
|
||||
"XL": 90,
|
||||
"XXL": 90
|
||||
},
|
||||
"asla": {
|
||||
"XS": 1,
|
||||
"S": 1,
|
||||
"M": 1,
|
||||
"L": 1,
|
||||
"XL": 1,
|
||||
"XXL": 1
|
||||
},
|
||||
"psla": {
|
||||
"XS": 3,
|
||||
"S": 6,
|
||||
"M": 9,
|
||||
"L": 15,
|
||||
"XL": 30,
|
||||
"XXL": 30
|
||||
},
|
||||
"ttfr": {
|
||||
"XS": 24,
|
||||
"S": 24,
|
||||
"M": 24,
|
||||
"L": 24,
|
||||
"XL": 24,
|
||||
"XXL": 24
|
||||
},
|
||||
"cresp": {
|
||||
"XS": 24,
|
||||
"S": 24,
|
||||
"M": 24,
|
||||
"L": 24,
|
||||
"XL": 24,
|
||||
"XXL": 24
|
||||
}
|
||||
},
|
||||
"brandColors": {
|
||||
"air": "#40248f",
|
||||
"airwick": "#72248f",
|
||||
"amicasa": "#6d248f",
|
||||
"biofreeze": "#4d248f",
|
||||
"calgon": "#8f2424",
|
||||
"cillit": "#24868f",
|
||||
"clearasil": "#8f4424",
|
||||
"destop": "#37248f",
|
||||
"dettol": "#248f4b",
|
||||
"dobendan": "#248f5f",
|
||||
"durex": "#568f24",
|
||||
"finish": "#8d248f",
|
||||
"fullmarks": "#69248f",
|
||||
"fybogel": "#8f5924",
|
||||
"gaviscon": "#248f81",
|
||||
"harpic": "#8f2469",
|
||||
"intima": "#608f24",
|
||||
"k": "#428f24",
|
||||
"kukident": "#8f2489",
|
||||
"lemsip": "#8f8424",
|
||||
"lovela": "#30248f",
|
||||
"luftal": "#2e8f24",
|
||||
"lysol": "#248f46",
|
||||
"multibrand": "#68248f",
|
||||
"naldecon": "#8f244f",
|
||||
"napisan": "#648f24",
|
||||
"nurofen": "#62248f",
|
||||
"nuromol": "#368f24",
|
||||
"oh": "#24578f",
|
||||
"optrex": "#8f6024",
|
||||
"picot": "#86248f",
|
||||
"reckitt.com": "#8f2524",
|
||||
"resolve": "#248f2b",
|
||||
"ritm2653436": "#8f6624",
|
||||
"sagrotan": "#8f2474",
|
||||
"sbp": "#248f5f",
|
||||
"sico": "#8f2476",
|
||||
"sole": "#24378f",
|
||||
"stmarcs": "#49248f",
|
||||
"strepfen": "#70248f",
|
||||
"strepsils": "#8f245b",
|
||||
"tempra": "#8f2466",
|
||||
"vanish": "#748f24",
|
||||
"veet": "#24528f",
|
||||
"veja": "#8f2436",
|
||||
"woolite": "#8f248d"
|
||||
},
|
||||
"snowColors": {
|
||||
"activityField": "#eff1e0",
|
||||
"activityHeader": "#ccccf5",
|
||||
"assignedToField": "#ddffe1",
|
||||
"requesterComment": "#efdcee",
|
||||
"stateField": "#ffd666",
|
||||
"workNotes": "#e1d3b8"
|
||||
},
|
||||
"statesOrder": [
|
||||
"1",
|
||||
"-5",
|
||||
"2",
|
||||
"3",
|
||||
"5",
|
||||
"6",
|
||||
"8",
|
||||
"4",
|
||||
"7",
|
||||
"12",
|
||||
"9",
|
||||
"10",
|
||||
"13"
|
||||
],
|
||||
"displayCurrency": "GBP",
|
||||
"colleagues": [
|
||||
"Alesya Prolagayeva",
|
||||
"Elizaveta Nyarko",
|
||||
"Tatyana Samoylenko",
|
||||
"Oksana Batyuk",
|
||||
"Taras Shevchenko",
|
||||
"Herman Bykanov (Inactive)",
|
||||
"Levon Mkrtchyan (Inactive)",
|
||||
"Dmitry Koziyev (Inactive)"
|
||||
],
|
||||
"latamAssignees": [
|
||||
"Damian Casasnovas",
|
||||
"Jesica Greco"
|
||||
],
|
||||
"jiraColumns": [
|
||||
"Analysis",
|
||||
"Approval",
|
||||
"TO DO",
|
||||
"On Hold/Blocked",
|
||||
"In Progress",
|
||||
"Ready for UAT",
|
||||
"UAT",
|
||||
"Ready To Deploy to Prod",
|
||||
"Released",
|
||||
"Waiting PO",
|
||||
"Closed",
|
||||
"Cancelled"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+630
@@ -0,0 +1,630 @@
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Ticket, TicketStatus } from './types';
|
||||
|
||||
// Single shared pool. DATABASE_URL points at the `forge` Postgres database; the
|
||||
// schema self-bootstraps in initDB() on boot (no ORM, no migrations), mirroring
|
||||
// the Husky template's approach.
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
number TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
short_desc TEXT NOT NULL DEFAULT '',
|
||||
assigned_to TEXT,
|
||||
assignment_group TEXT,
|
||||
brand TEXT,
|
||||
market TEXT,
|
||||
business_unit TEXT,
|
||||
requested_for TEXT,
|
||||
opened_at TIMESTAMPTZ,
|
||||
due_date DATE,
|
||||
state_changed_at TIMESTAMPTZ,
|
||||
state_changed_by TEXT,
|
||||
last_activity_at TIMESTAMPTZ,
|
||||
last_activity_by TEXT,
|
||||
last_comment TEXT,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
final_cost NUMERIC,
|
||||
ttfr_minutes INTEGER,
|
||||
client_resp_minutes INTEGER,
|
||||
fulfillment_date TIMESTAMPTZ,
|
||||
first_reply_at TIMESTAMPTZ,
|
||||
first_assigned_date TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
jira JSONB,
|
||||
activity JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets (status)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_state_idx ON tickets (state)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_group_idx ON tickets (assignment_group)`);
|
||||
|
||||
// Analytics columns (additive — never changes an existing column's shape).
|
||||
for (const col of [
|
||||
'currency_code TEXT', 'opened_by TEXT', 'opened_date DATE', 'closed_date DATE',
|
||||
'to_do_at TIMESTAMPTZ', 'in_uat_at TIMESTAMPTZ', 'jira_key TEXT',
|
||||
'ticket_year INTEGER', 'size TEXT',
|
||||
'po_number TEXT', 'invoiced TEXT',
|
||||
]) {
|
||||
await pool.query(`ALTER TABLE tickets ADD COLUMN IF NOT EXISTS ${col}`);
|
||||
}
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_year_idx ON tickets (ticket_year)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_closed_date_idx ON tickets (closed_date)`);
|
||||
|
||||
// Key/value config (SLA norms, thresholds, FX, size thresholds, palettes).
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_id TEXT UNIQUE NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||
expires_at TIMESTAMPTZ
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
)
|
||||
`);
|
||||
// Existing deployments: add the role column (defaults viewer for prior rows).
|
||||
await pool.query(`ALTER TABLE app_users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'viewer'`);
|
||||
|
||||
await seedUser();
|
||||
await seedConfig();
|
||||
await seedFromArchives();
|
||||
}
|
||||
|
||||
// --- Config (SLA norms / thresholds / FX / size / palettes) ----------------
|
||||
export interface ForgeConfig {
|
||||
insightsThresholds: Record<string, number>;
|
||||
fxRates: Record<string, number>;
|
||||
sizeThresholds: Record<string, number>;
|
||||
norms: Record<string, Record<string, number>>;
|
||||
brandColors: Record<string, string>;
|
||||
snowColors: Record<string, string>;
|
||||
statesOrder: string[];
|
||||
displayCurrency: string;
|
||||
colleagues: string[];
|
||||
latamAssignees: string[];
|
||||
jiraColumns: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ForgeConfig = {
|
||||
insightsThresholds: {},
|
||||
fxRates: { GBP: 1, EUR: 1.2, MXN: 20 },
|
||||
sizeThresholds: { XS: 120, S: 300, M: 600, L: 1200, XL: 3000, XXL: 9000 },
|
||||
norms: {},
|
||||
brandColors: {},
|
||||
snowColors: {},
|
||||
statesOrder: [],
|
||||
displayCurrency: 'GBP',
|
||||
colleagues: [],
|
||||
latamAssignees: [],
|
||||
jiraColumns: [],
|
||||
};
|
||||
|
||||
let _config: ForgeConfig | null = null;
|
||||
export function loadConfig(): ForgeConfig {
|
||||
if (_config) return _config;
|
||||
let cfg: ForgeConfig;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'config.json'), 'utf-8'));
|
||||
cfg = { ...DEFAULT_CONFIG, ...raw };
|
||||
} catch {
|
||||
cfg = DEFAULT_CONFIG;
|
||||
}
|
||||
_config = cfg;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function seedConfig(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
for (const [key, value] of Object.entries(cfg)) {
|
||||
await pool.query(
|
||||
`INSERT INTO app_config (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, JSON.stringify(value)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a cost to GBP and map to a t-shirt size (largest nominal that fits,
|
||||
// the chart path used by the initial app). Zero/absent cost → null ("no cost").
|
||||
const SIZE_ORDER = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
|
||||
export function sizeOf(finalCost: number | null, currencyCode: string | null, cfg: ForgeConfig): string | null {
|
||||
if (finalCost == null || !(finalCost > 0)) return null;
|
||||
const rate = cfg.fxRates[(currencyCode || 'GBP').toUpperCase()] ?? 1; // units per 1 GBP
|
||||
const gbp = finalCost / rate;
|
||||
const th = cfg.sizeThresholds;
|
||||
let best = 'XS'; // largest nominal ≤ gbp, capped at XXL; sub-XS cost still reads XS
|
||||
for (const s of SIZE_ORDER) if (gbp >= (th[s] ?? Infinity)) best = s;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Seed the single login account from AUTH_USER/AUTH_PASS on first boot. If the
|
||||
// user already exists its password is left untouched (change it in the DB, or
|
||||
// delete the row and reboot to re-seed). No-op when the env vars are unset.
|
||||
async function seedUser(): Promise<void> {
|
||||
const username = process.env.AUTH_USER;
|
||||
const password = process.env.AUTH_PASS;
|
||||
if (!username || !password) {
|
||||
console.warn('AUTH_USER/AUTH_PASS unset — no login account seeded (read API will reject all logins)');
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query('SELECT 1 FROM app_users WHERE username = $1', [username]);
|
||||
if (rows.length > 0) {
|
||||
// Bootstrap account is always admin (fixes the default 'viewer' for pre-role rows).
|
||||
await pool.query(`UPDATE app_users SET role='admin' WHERE username=$1 AND role<>'admin'`, [username]);
|
||||
return;
|
||||
}
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await pool.query('INSERT INTO app_users (username, password_hash, role) VALUES ($1, $2, $3)', [username, hash, 'admin']);
|
||||
console.log(`Seeded admin account: ${username}`);
|
||||
}
|
||||
|
||||
// --- User management (Project Leadership / Admin) --------------------------
|
||||
export type Role = 'viewer' | 'pm' | 'lead' | 'admin';
|
||||
export const ROLES: Role[] = ['viewer', 'pm', 'lead', 'admin'];
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return typeof v === 'string' && (ROLES as string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface AppUser { id: number; username: string; role: Role; createdAt: string | null; lastLoginAt: string | null; }
|
||||
|
||||
export async function listUsers(): Promise<AppUser[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, role, created_at, last_login_at FROM app_users ORDER BY username`,
|
||||
);
|
||||
return rows.map(r => ({
|
||||
id: r.id, username: r.username, role: r.role,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
lastLoginAt: r.last_login_at instanceof Date ? r.last_login_at.toISOString() : r.last_login_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Create a user. Returns 'exists' if the username is taken, else the new user.
|
||||
export async function createUser(username: string, password: string, role: Role): Promise<AppUser | 'exists'> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO app_users (username, password_hash, role) VALUES ($1, $2, $3)
|
||||
RETURNING id, username, role, created_at, last_login_at`,
|
||||
[username, hash, role],
|
||||
);
|
||||
const r = rows[0];
|
||||
return { id: r.id, username: r.username, role: r.role,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, lastLoginAt: null };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === '23505') return 'exists'; // unique violation
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function countAdmins(): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: string }>(`SELECT count(*)::text AS n FROM app_users WHERE role = 'admin'`);
|
||||
return Number(rows[0]?.n ?? '0');
|
||||
}
|
||||
|
||||
export async function getUserRole(username: string): Promise<Role | null> {
|
||||
const { rows } = await pool.query<{ role: Role }>(`SELECT role FROM app_users WHERE username = $1`, [username]);
|
||||
return rows[0]?.role ?? null;
|
||||
}
|
||||
|
||||
// Delete a user AND purge their active sessions (connect-pg-simple stores the
|
||||
// session as JSON in user_sessions.sess), so a removed user is logged out at once
|
||||
// instead of keeping their cached role for the cookie's lifetime.
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
await pool.query(`DELETE FROM app_users WHERE username = $1`, [username]);
|
||||
await pool.query(`DELETE FROM user_sessions WHERE (sess -> 'user' ->> 'username') = $1`, [username])
|
||||
.catch(err => console.error('session purge failed:', (err as Error).message));
|
||||
}
|
||||
|
||||
// --- Empty helpers ---------------------------------------------------------
|
||||
function ts(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Derive the calendar year from the best available opened date.
|
||||
function yearOf(...vals: (string | null)[]): number | null {
|
||||
for (const v of vals) {
|
||||
if (v) { const y = Number(String(v).slice(0, 4)); if (y > 2000 && y < 3000) return y; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reshape a raw active-archive record into a normalized Ticket.
|
||||
function fromActive(r: Record<string, unknown>, cfg: ForgeConfig): Ticket {
|
||||
const meta = (r.meta ?? {}) as Record<string, unknown>;
|
||||
const finalCost = num(meta.finalCost);
|
||||
const currencyCode = ts(meta.currencyCode);
|
||||
return {
|
||||
number: String(r.number),
|
||||
status: 'active',
|
||||
state: String(r.state ?? ''),
|
||||
shortDesc: String(r.shortDesc ?? ''),
|
||||
assignedTo: ts(r.assignedTo),
|
||||
assignmentGroup: ts(r.assignmentGroup),
|
||||
brand: ts(meta.brand),
|
||||
market: ts(meta.market),
|
||||
businessUnit: ts(meta.businessUnit),
|
||||
requestedFor: ts(meta.requestedFor),
|
||||
openedBy: null,
|
||||
openedAt: ts(meta.openedAt),
|
||||
openedDate: ts(meta.openedAt),
|
||||
closedDate: null,
|
||||
toDoAt: null,
|
||||
inUatAt: null,
|
||||
jiraKey: null,
|
||||
currencyCode,
|
||||
ticketYear: yearOf(ts(meta.openedAt)),
|
||||
size: sizeOf(finalCost, currencyCode, cfg),
|
||||
poNumber: null,
|
||||
invoiced: null,
|
||||
dueDate: ts(meta.dueDate),
|
||||
stateChangedAt: ts(meta.stateChangedAt),
|
||||
stateChangedBy: ts(meta.stateChangedBy),
|
||||
lastActivityAt: ts(meta.lastActivityAt),
|
||||
lastActivityBy: ts(meta.lastActivityBy),
|
||||
lastComment: ts(r.lastComment),
|
||||
description: ts(meta.description),
|
||||
link: ts(r.link),
|
||||
finalCost,
|
||||
ttfrMinutes: null,
|
||||
clientRespMinutes: null,
|
||||
fulfillmentDate: null,
|
||||
firstReplyAt: null,
|
||||
firstAssignedDate: null,
|
||||
updatedAt: ts(r._updated),
|
||||
jira: null,
|
||||
activity: Array.isArray(meta.activity) ? (meta.activity as Ticket['activity']) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function fromClosed(number: string, r: Record<string, unknown>, cfg: ForgeConfig): Ticket {
|
||||
const finalCost = num(r.finalCost);
|
||||
const currencyCode = ts(r.currencyCode);
|
||||
return {
|
||||
number,
|
||||
status: 'closed',
|
||||
state: String(r.state ?? ''),
|
||||
shortDesc: String(r.shortDesc ?? ''),
|
||||
assignedTo: ts(r.assignedTo),
|
||||
assignmentGroup: ts(r.assignmentGroup),
|
||||
brand: ts(r.brand),
|
||||
market: ts(r.market),
|
||||
businessUnit: ts(r.businessUnit),
|
||||
requestedFor: ts(r.requestedFor),
|
||||
openedBy: ts(r.openedBy),
|
||||
openedAt: ts(r.openedAt),
|
||||
openedDate: ts(r.openedDate) ?? ts(r.openedAt),
|
||||
closedDate: ts(r.closedDate),
|
||||
toDoAt: ts(r.toDoAt),
|
||||
inUatAt: ts(r.inUatAt),
|
||||
jiraKey: ts(r.jiraKey),
|
||||
currencyCode,
|
||||
ticketYear: (num(r.year) ?? yearOf(ts(r.closedDate), ts(r.openedDate), ts(r.openedAt))),
|
||||
size: sizeOf(finalCost, currencyCode, cfg),
|
||||
poNumber: null,
|
||||
invoiced: null,
|
||||
dueDate: null,
|
||||
stateChangedAt: ts(r.stateChangedAt),
|
||||
stateChangedBy: ts(r.stateChangedBy),
|
||||
lastActivityAt: ts(r.lastActivityAt),
|
||||
lastActivityBy: ts(r.lastActivityBy),
|
||||
lastComment: null,
|
||||
description: null,
|
||||
link: null,
|
||||
finalCost,
|
||||
ttfrMinutes: num(r.ttfrMinutes),
|
||||
clientRespMinutes: num(r.clientRespMinutes),
|
||||
fulfillmentDate: ts(r.fulfillmentDate),
|
||||
firstReplyAt: ts(r.firstReplyAt),
|
||||
firstAssignedDate: ts(r.firstAssignedDate),
|
||||
updatedAt: ts(r._updated),
|
||||
jira: null,
|
||||
activity: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Build the normalized ticket list from the bundled JSON archives. Jira info
|
||||
// (keyed by ticket number) is attached to BOTH active and closed tickets.
|
||||
//
|
||||
// Order matters: closed first, then active. A ticket number can appear in both
|
||||
// archives (a still-open request whose analytics row was also cached); upsert
|
||||
// applies later entries last, so putting active last makes the live active
|
||||
// board win over the stale closed snapshot.
|
||||
// Finance rows keyed by ticket number: cost / currency / PO / invoiced.
|
||||
interface FinanceRow { cost?: unknown; currency?: unknown; po?: unknown; invoiced?: unknown; }
|
||||
function loadFinance(dir: string): Record<string, FinanceRow> {
|
||||
try {
|
||||
return (JSON.parse(fs.readFileSync(path.join(dir, 'finance.json'), 'utf-8')).rows ?? {}) as Record<string, FinanceRow>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// Enrich a ticket from its finance row: fill missing cost/currency (recomputing
|
||||
// size), and set the PO number ('' = in finance with no PO yet) + invoiced flag.
|
||||
function applyFinance(t: Ticket, fin: FinanceRow | undefined, cfg: ForgeConfig): void {
|
||||
if (!fin) return;
|
||||
t.poNumber = fin.po == null ? null : String(fin.po);
|
||||
t.invoiced = ts(fin.invoiced);
|
||||
if (t.finalCost == null) {
|
||||
const c = num(fin.cost);
|
||||
if (c != null) {
|
||||
t.finalCost = c;
|
||||
if (!t.currencyCode) t.currencyCode = ts(fin.currency);
|
||||
t.size = sizeOf(t.finalCost, t.currencyCode, cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadArchiveTickets(): Ticket[] {
|
||||
const dir = path.join(__dirname, 'data');
|
||||
const cfg = loadConfig();
|
||||
const finance = loadFinance(dir);
|
||||
const activeTickets: Ticket[] = [];
|
||||
const closedTickets: Ticket[] = [];
|
||||
let jira: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const active = JSON.parse(fs.readFileSync(path.join(dir, 'active_archive.json'), 'utf-8'));
|
||||
jira = (active.jira ?? {}) as Record<string, unknown>;
|
||||
for (const raw of active.tickets ?? []) {
|
||||
const t = fromActive(raw as Record<string, unknown>, cfg);
|
||||
const j = (jira[t.number] as { key?: string | null }) ?? null;
|
||||
t.jira = j;
|
||||
if (j?.key) t.jiraKey = j.key;
|
||||
applyFinance(t, finance[t.number], cfg);
|
||||
activeTickets.push(t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('seed: active archive skipped —', (err as Error).message);
|
||||
}
|
||||
|
||||
try {
|
||||
const closed = JSON.parse(fs.readFileSync(path.join(dir, 'closed_archive.json'), 'utf-8'));
|
||||
const map = (closed.tickets ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const [number, raw] of Object.entries(map)) {
|
||||
const t = fromClosed(number, raw, cfg);
|
||||
const j = (jira[number] as { key?: string | null }) ?? null;
|
||||
t.jira = j;
|
||||
if (!t.jiraKey && j?.key) t.jiraKey = j.key;
|
||||
applyFinance(t, finance[number], cfg);
|
||||
closedTickets.push(t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('seed: closed archive skipped —', (err as Error).message);
|
||||
}
|
||||
|
||||
return [...closedTickets, ...activeTickets];
|
||||
}
|
||||
|
||||
// One-time seed: if the tickets table is empty, load the bundled JSON archives.
|
||||
// Idempotent via upsert, so a redeploy never duplicates. Skipped silently once
|
||||
// the table has rows (real syncs from the extension take over).
|
||||
async function seedFromArchives(): Promise<void> {
|
||||
const { rows } = await pool.query<{ n: string }>(`SELECT count(*)::text AS n FROM tickets`);
|
||||
if (Number(rows[0]?.n ?? '0') > 0) return;
|
||||
|
||||
const tickets = loadArchiveTickets();
|
||||
if (tickets.length === 0) return;
|
||||
await upsertTickets(tickets);
|
||||
console.log(`Seeded ${tickets.length} tickets from bundled archives`);
|
||||
}
|
||||
|
||||
// Replace all tickets with a fresh load from the archives. Used by the reseed
|
||||
// CLI (server/reseed.ts) to reload after regenerating the archives from a dump.
|
||||
export async function reseedFromArchives(): Promise<number> {
|
||||
const tickets = loadArchiveTickets();
|
||||
await pool.query('TRUNCATE tickets');
|
||||
await upsertTickets(tickets);
|
||||
return tickets.length;
|
||||
}
|
||||
|
||||
// Attach-only Jira enrichment: update ONLY the `jira` JSONB (merged) and `jira_key`
|
||||
// on tickets that already exist, keyed by RITM number. Never touches
|
||||
// status/state/assignee/activity (see ADR — a Jira payload must not ride the
|
||||
// clobbering ticket upsert). Returns how many existing tickets matched.
|
||||
export interface JiraAttach { number: string; jira: Record<string, unknown>; jiraKey?: string | null; }
|
||||
export async function attachJira(items: JiraAttach[]): Promise<number> {
|
||||
if (items.length === 0) return 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
let matched = 0;
|
||||
for (const it of items) {
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE tickets
|
||||
SET jira = COALESCE(jira, '{}'::jsonb) || $2::jsonb,
|
||||
jira_key = COALESCE($3, jira_key),
|
||||
synced_at = NOW()
|
||||
WHERE number = $1`,
|
||||
[it.number, JSON.stringify(it.jira ?? {}), it.jiraKey ?? null],
|
||||
);
|
||||
matched += rowCount ?? 0;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return matched;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert a batch of tickets by number. Used by both the seed and the sync
|
||||
// ingest endpoint. Runs in a single transaction.
|
||||
export async function upsertTickets(tickets: Ticket[]): Promise<number> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const t of tickets) {
|
||||
await client.query(
|
||||
`INSERT INTO tickets (
|
||||
number, status, state, short_desc, assigned_to, assignment_group,
|
||||
brand, market, business_unit, requested_for, opened_at, due_date,
|
||||
state_changed_at, state_changed_by, last_activity_at, last_activity_by,
|
||||
last_comment, description, link, final_cost, ttfr_minutes,
|
||||
client_resp_minutes, fulfillment_date, first_reply_at, first_assigned_date,
|
||||
updated_at, jira, activity,
|
||||
currency_code, opened_by, opened_date, closed_date, to_do_at, in_uat_at,
|
||||
jira_key, ticket_year, size, po_number, invoiced, synced_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,
|
||||
$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39, NOW()
|
||||
)
|
||||
-- Non-destructive: the sync collector sends a thin payload (state, assignee,
|
||||
-- group, timestamps). For the rich fields it omits (brand/market/description/
|
||||
-- lastComment/jira/analytics), COALESCE keeps the existing value when the
|
||||
-- incoming one is NULL, so a real sync never nulls seeded richness. Activity
|
||||
-- is kept unless the incoming payload actually carries entries.
|
||||
ON CONFLICT (number) DO UPDATE SET
|
||||
status=EXCLUDED.status, state=EXCLUDED.state, short_desc=EXCLUDED.short_desc,
|
||||
assigned_to=EXCLUDED.assigned_to, assignment_group=EXCLUDED.assignment_group,
|
||||
opened_at=COALESCE(EXCLUDED.opened_at, tickets.opened_at),
|
||||
due_date=COALESCE(EXCLUDED.due_date, tickets.due_date),
|
||||
state_changed_at=COALESCE(EXCLUDED.state_changed_at, tickets.state_changed_at),
|
||||
state_changed_by=COALESCE(EXCLUDED.state_changed_by, tickets.state_changed_by),
|
||||
last_activity_at=EXCLUDED.last_activity_at, last_activity_by=EXCLUDED.last_activity_by,
|
||||
last_comment=COALESCE(EXCLUDED.last_comment, tickets.last_comment),
|
||||
description=COALESCE(EXCLUDED.description, tickets.description),
|
||||
link=COALESCE(EXCLUDED.link, tickets.link),
|
||||
brand=COALESCE(EXCLUDED.brand, tickets.brand),
|
||||
market=COALESCE(EXCLUDED.market, tickets.market),
|
||||
business_unit=COALESCE(EXCLUDED.business_unit, tickets.business_unit),
|
||||
requested_for=COALESCE(EXCLUDED.requested_for, tickets.requested_for),
|
||||
final_cost=COALESCE(EXCLUDED.final_cost, tickets.final_cost),
|
||||
ttfr_minutes=COALESCE(EXCLUDED.ttfr_minutes, tickets.ttfr_minutes),
|
||||
client_resp_minutes=COALESCE(EXCLUDED.client_resp_minutes, tickets.client_resp_minutes),
|
||||
fulfillment_date=COALESCE(EXCLUDED.fulfillment_date, tickets.fulfillment_date),
|
||||
first_reply_at=COALESCE(EXCLUDED.first_reply_at, tickets.first_reply_at),
|
||||
first_assigned_date=COALESCE(EXCLUDED.first_assigned_date, tickets.first_assigned_date),
|
||||
updated_at=EXCLUDED.updated_at,
|
||||
jira=COALESCE(EXCLUDED.jira, tickets.jira),
|
||||
activity=CASE WHEN jsonb_array_length(EXCLUDED.activity) > 0
|
||||
THEN EXCLUDED.activity ELSE tickets.activity END,
|
||||
currency_code=COALESCE(EXCLUDED.currency_code, tickets.currency_code),
|
||||
opened_by=COALESCE(EXCLUDED.opened_by, tickets.opened_by),
|
||||
opened_date=COALESCE(EXCLUDED.opened_date, tickets.opened_date),
|
||||
closed_date=COALESCE(EXCLUDED.closed_date, tickets.closed_date),
|
||||
to_do_at=COALESCE(EXCLUDED.to_do_at, tickets.to_do_at),
|
||||
in_uat_at=COALESCE(EXCLUDED.in_uat_at, tickets.in_uat_at),
|
||||
jira_key=COALESCE(EXCLUDED.jira_key, tickets.jira_key),
|
||||
ticket_year=COALESCE(EXCLUDED.ticket_year, tickets.ticket_year),
|
||||
size=COALESCE(EXCLUDED.size, tickets.size),
|
||||
po_number=COALESCE(EXCLUDED.po_number, tickets.po_number),
|
||||
invoiced=COALESCE(EXCLUDED.invoiced, tickets.invoiced),
|
||||
synced_at=NOW()`,
|
||||
[
|
||||
t.number, t.status, t.state, t.shortDesc, t.assignedTo, t.assignmentGroup,
|
||||
t.brand, t.market, t.businessUnit, t.requestedFor, t.openedAt, t.dueDate,
|
||||
t.stateChangedAt, t.stateChangedBy, t.lastActivityAt, t.lastActivityBy,
|
||||
t.lastComment, t.description, t.link, t.finalCost, t.ttfrMinutes,
|
||||
t.clientRespMinutes, t.fulfillmentDate, t.firstReplyAt, t.firstAssignedDate,
|
||||
t.updatedAt, t.jira ? JSON.stringify(t.jira) : null, JSON.stringify(t.activity ?? []),
|
||||
t.currencyCode, t.openedBy, t.openedDate, t.closedDate, t.toDoAt, t.inUatAt,
|
||||
t.jiraKey, t.ticketYear, t.size, t.poNumber, t.invoiced,
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return tickets.length;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Map a DB row back to the wire Ticket shape.
|
||||
export function rowToTicket(r: Record<string, unknown>): Ticket {
|
||||
const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : (v as string | null));
|
||||
// DATE columns come back as a JS Date at LOCAL midnight; toISOString() would
|
||||
// shift the calendar day under a non-UTC offset. Emit YYYY-MM-DD from local parts.
|
||||
const dateOnly = (v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Date) {
|
||||
const y = v.getFullYear();
|
||||
const m = String(v.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(v.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
return {
|
||||
number: r.number as string,
|
||||
status: r.status as TicketStatus,
|
||||
state: (r.state as string) ?? '',
|
||||
shortDesc: (r.short_desc as string) ?? '',
|
||||
assignedTo: (r.assigned_to as string) ?? null,
|
||||
assignmentGroup: (r.assignment_group as string) ?? null,
|
||||
brand: (r.brand as string) ?? null,
|
||||
market: (r.market as string) ?? null,
|
||||
businessUnit: (r.business_unit as string) ?? null,
|
||||
requestedFor: (r.requested_for as string) ?? null,
|
||||
openedBy: (r.opened_by as string) ?? null,
|
||||
openedAt: iso(r.opened_at),
|
||||
openedDate: dateOnly(r.opened_date),
|
||||
closedDate: dateOnly(r.closed_date),
|
||||
toDoAt: iso(r.to_do_at),
|
||||
inUatAt: iso(r.in_uat_at),
|
||||
jiraKey: (r.jira_key as string) ?? null,
|
||||
currencyCode: (r.currency_code as string) ?? null,
|
||||
ticketYear: r.ticket_year != null ? Number(r.ticket_year) : null,
|
||||
size: (r.size as string) ?? null,
|
||||
poNumber: (r.po_number as string) ?? null,
|
||||
invoiced: (r.invoiced as string) ?? null,
|
||||
dueDate: dateOnly(r.due_date),
|
||||
stateChangedAt: iso(r.state_changed_at),
|
||||
stateChangedBy: (r.state_changed_by as string) ?? null,
|
||||
lastActivityAt: iso(r.last_activity_at),
|
||||
lastActivityBy: (r.last_activity_by as string) ?? null,
|
||||
lastComment: (r.last_comment as string) ?? null,
|
||||
description: (r.description as string) ?? null,
|
||||
link: (r.link as string) ?? null,
|
||||
finalCost: r.final_cost != null ? Number(r.final_cost) : null,
|
||||
ttfrMinutes: r.ttfr_minutes != null ? Number(r.ttfr_minutes) : null,
|
||||
clientRespMinutes: r.client_resp_minutes != null ? Number(r.client_resp_minutes) : null,
|
||||
fulfillmentDate: iso(r.fulfillment_date),
|
||||
firstReplyAt: iso(r.first_reply_at),
|
||||
firstAssignedDate: iso(r.first_assigned_date),
|
||||
updatedAt: iso(r.updated_at),
|
||||
jira: (r.jira as Ticket['jira']) ?? null,
|
||||
activity: (r.activity as Ticket['activity']) ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dotenv/config';
|
||||
import { pool, initDB } from './db';
|
||||
import { createToken } from './tokens';
|
||||
|
||||
// CLI: mint a sync token for the Chrome extension without going through the
|
||||
// HTTP endpoint. Usage: npx tsx server/mint-token.ts "my laptop"
|
||||
(async () => {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
const label = process.argv.slice(2).join(' ') || 'sync-extension';
|
||||
await initDB();
|
||||
const raw = await createToken(label);
|
||||
console.log('\nSync token (shown once — paste it into the extension options):\n');
|
||||
console.log(' ' + raw + '\n');
|
||||
console.log(`label: ${label}`);
|
||||
await pool.end();
|
||||
})();
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dotenv/config';
|
||||
import { pool, initDB, reseedFromArchives } from './db';
|
||||
|
||||
// CLI: reload the tickets table from server/data/*.json, replacing whatever is
|
||||
// there. Use after regenerating the archives (e.g. from a Let it Snow storage
|
||||
// dump via scripts/dump-to-archives.mjs). Destructive: TRUNCATEs tickets first.
|
||||
// npx tsx server/reseed.ts (or: npm run reseed)
|
||||
(async () => {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
await initDB();
|
||||
const n = await reseedFromArchives();
|
||||
console.log(`Reseeded ${n} tickets from server/data archives`);
|
||||
await pool.end();
|
||||
})();
|
||||
@@ -0,0 +1,57 @@
|
||||
import { pool, rowToTicket } from './db';
|
||||
import type { Ticket, TicketFilters, StatsResponse } from './types';
|
||||
|
||||
// List tickets with optional filters. Text search spans number + short desc +
|
||||
// assignee. Ordered newest-activity-first so the operational board is useful.
|
||||
export async function listTickets(f: TicketFilters): Promise<Ticket[]> {
|
||||
const where: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const add = (sql: string, val: unknown) => { params.push(val); where.push(sql.replace('$?', `$${params.length}`)); };
|
||||
|
||||
if (f.status) add('status = $?', f.status);
|
||||
if (f.state) add('state = $?', f.state);
|
||||
if (f.group) add('assignment_group = $?', f.group);
|
||||
if (f.assignee) add('assigned_to = $?', f.assignee);
|
||||
if (f.q) {
|
||||
params.push(`%${f.q.toLowerCase()}%`);
|
||||
const p = `$${params.length}`;
|
||||
where.push(`(lower(number) LIKE ${p} OR lower(short_desc) LIKE ${p} OR lower(coalesce(assigned_to,'')) LIKE ${p})`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT * FROM tickets
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY last_activity_at DESC NULLS LAST, number DESC
|
||||
LIMIT 2000
|
||||
`;
|
||||
const { rows } = await pool.query(sql, params);
|
||||
return rows.map(rowToTicket);
|
||||
}
|
||||
|
||||
export async function getTicket(number: string): Promise<Ticket | null> {
|
||||
const { rows } = await pool.query(`SELECT * FROM tickets WHERE number = $1`, [number]);
|
||||
return rows[0] ? rowToTicket(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<StatsResponse> {
|
||||
const [totals, byState, byGroup, byAssignee] = await Promise.all([
|
||||
pool.query<{ status: string; n: string }>(`SELECT status, count(*)::text AS n FROM tickets GROUP BY status`),
|
||||
pool.query<{ state: string; n: string }>(
|
||||
`SELECT state, count(*)::text AS n FROM tickets WHERE status='active' GROUP BY state ORDER BY count(*) DESC`),
|
||||
pool.query<{ g: string; n: string }>(
|
||||
`SELECT coalesce(assignment_group,'—') AS g, count(*)::text AS n FROM tickets WHERE status='active' GROUP BY g ORDER BY count(*) DESC`),
|
||||
pool.query<{ a: string; n: string }>(
|
||||
`SELECT coalesce(assigned_to,'—') AS a, count(*)::text AS n FROM tickets WHERE status='active' GROUP BY a ORDER BY count(*) DESC LIMIT 20`),
|
||||
]);
|
||||
|
||||
const active = Number(totals.rows.find(r => r.status === 'active')?.n ?? '0');
|
||||
const closed = Number(totals.rows.find(r => r.status === 'closed')?.n ?? '0');
|
||||
return {
|
||||
total: active + closed,
|
||||
active,
|
||||
closed,
|
||||
byState: byState.rows.map(r => ({ state: r.state || '—', count: Number(r.n) })),
|
||||
byGroup: byGroup.rows.map(r => ({ group: r.g, count: Number(r.n) })),
|
||||
byAssignee: byAssignee.rows.map(r => ({ assignee: r.a, count: Number(r.n) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mintToken, parseToken, sha256Hex } from './tokens';
|
||||
|
||||
describe('token format', () => {
|
||||
it('mints a fg_ token that parses back to a secret whose hash matches', () => {
|
||||
const t = mintToken();
|
||||
expect(t.raw.startsWith('fg_')).toBe(true);
|
||||
const parsed = parseToken(t.raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.tokenId).toBe(t.tokenId);
|
||||
expect(sha256Hex(parsed!.secret)).toBe(t.tokenHash);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens', () => {
|
||||
expect(parseToken('nope')).toBeNull();
|
||||
expect(parseToken('fg_short')).toBeNull();
|
||||
expect(parseToken('')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses ids/secrets that contain underscores (base64url)', () => {
|
||||
// Fixed-offset parsing must not split on the first underscore.
|
||||
const t = mintToken();
|
||||
const parsed = parseToken(t.raw)!;
|
||||
expect(`fg_${parsed.tokenId}_${parsed.secret}`).toBe(t.raw);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request } from 'express';
|
||||
import { pool } from './db';
|
||||
|
||||
// API tokens for the Chrome sync extension. Format: fg_<token_id>_<secret>.
|
||||
// Only the SHA-256 of the secret is stored; token_id is a non-secret locator.
|
||||
// Mirrors the Husky template's scheme (see its ADR), trimmed to what the sync
|
||||
// route needs. The raw token is shown ONCE at mint time and never logged.
|
||||
|
||||
const PREFIX = 'fg_';
|
||||
const ID_BYTES = 9; // ~12 url-safe chars
|
||||
const SECRET_BYTES = 32; // 256-bit secret
|
||||
const ID_LEN = Math.ceil((ID_BYTES * 4) / 3); // base64url unpadded → 12
|
||||
const TTL_DAYS = 180;
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return crypto.createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export interface MintedToken { raw: string; tokenId: string; tokenHash: string; }
|
||||
|
||||
export function mintToken(): MintedToken {
|
||||
const tokenId = crypto.randomBytes(ID_BYTES).toString('base64url');
|
||||
const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url');
|
||||
return { raw: `${PREFIX}${tokenId}_${secret}`, tokenId, tokenHash: sha256Hex(secret) };
|
||||
}
|
||||
|
||||
// Fixed-length id, so parse by offset (base64url maps `/`→`_`, so first-`_`
|
||||
// splitting would truncate ids containing an underscore).
|
||||
export function parseToken(raw: string): { tokenId: string; secret: string } | null {
|
||||
if (!raw.startsWith(PREFIX)) return null;
|
||||
const rest = raw.slice(PREFIX.length);
|
||||
if (rest.length <= ID_LEN || rest[ID_LEN] !== '_') return null;
|
||||
const tokenId = rest.slice(0, ID_LEN);
|
||||
const secret = rest.slice(ID_LEN + 1);
|
||||
if (!tokenId || !secret) return null;
|
||||
return { tokenId, secret };
|
||||
}
|
||||
|
||||
export function bearerFromRequest(req: Request): string | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
||||
const t = auth.slice('Bearer '.length).trim();
|
||||
if (t) return t;
|
||||
}
|
||||
const x = req.headers['x-api-token'];
|
||||
if (typeof x === 'string' && x.trim()) return x.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve a presented bearer token to its token_id, or null (missing / malformed
|
||||
// / unknown / revoked / expired). Never throws on auth failure; never logs the
|
||||
// token. Best-effort last_used_at bump on success.
|
||||
export async function resolveToken(req: Request): Promise<{ tokenId: string } | null> {
|
||||
const raw = bearerFromRequest(req);
|
||||
if (!raw) return null;
|
||||
const parsed = parseToken(raw);
|
||||
if (!parsed) return null;
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: number; token_hash: string }>(
|
||||
`SELECT id, token_hash FROM api_tokens
|
||||
WHERE token_id = $1 AND revoked = false
|
||||
AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[parsed.tokenId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
const presented = Buffer.from(sha256Hex(parsed.secret));
|
||||
const stored = Buffer.from(row.token_hash);
|
||||
if (presented.length !== stored.length || !crypto.timingSafeEqual(presented, stored)) return null;
|
||||
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.id])
|
||||
.catch(err => console.error('api_tokens last_used_at update failed:', (err as Error).message));
|
||||
return { tokenId: parsed.tokenId };
|
||||
} catch (err) {
|
||||
console.error('resolveToken error:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mint + persist a token. Returns the raw token to show the user once.
|
||||
export async function createToken(label: string): Promise<string> {
|
||||
const t = mintToken();
|
||||
await pool.query(
|
||||
`INSERT INTO api_tokens (token_id, token_hash, label, expires_at)
|
||||
VALUES ($1, $2, $3, NOW() + ($4 || ' days')::interval)`,
|
||||
[t.tokenId, t.tokenHash, label || 'sync-extension', String(TTL_DAYS)],
|
||||
);
|
||||
return t.raw;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
id: number; tokenId: string; label: string;
|
||||
createdAt: string | null; lastUsedAt: string | null; expiresAt: string | null; revoked: boolean;
|
||||
}
|
||||
const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : (v as string | null));
|
||||
|
||||
export async function listTokens(): Promise<TokenInfo[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, token_id, label, created_at, last_used_at, expires_at, revoked
|
||||
FROM api_tokens ORDER BY created_at DESC`,
|
||||
);
|
||||
return rows.map(r => ({
|
||||
id: r.id, tokenId: r.token_id, label: r.label,
|
||||
createdAt: iso(r.created_at), lastUsedAt: iso(r.last_used_at), expiresAt: iso(r.expires_at), revoked: r.revoked,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function revokeToken(id: number): Promise<void> {
|
||||
await pool.query(`UPDATE api_tokens SET revoked = true WHERE id = $1`, [id]);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Shared server types. The client mirrors the wire shape in client/src/types.
|
||||
|
||||
export type TicketStatus = 'active' | 'closed';
|
||||
|
||||
export interface ActivityEntry {
|
||||
kind: 'comment' | 'worknote' | string;
|
||||
t: string;
|
||||
who: string;
|
||||
}
|
||||
|
||||
export interface JiraInfo {
|
||||
status?: string;
|
||||
statusChangedAt?: string;
|
||||
key?: string | null;
|
||||
url?: string | null;
|
||||
assignee?: string | null;
|
||||
statusDurations?: Record<string, number>; // status → milliseconds (chart #17)
|
||||
movements?: { at: string; who?: string; from?: string; to?: string }[];
|
||||
}
|
||||
|
||||
// One normalized ticket row, covering both the active (operational) and closed
|
||||
// (analytics) ServiceNow archives. Nullable fields are simply absent in one of
|
||||
// the two source shapes.
|
||||
export interface Ticket {
|
||||
number: string;
|
||||
status: TicketStatus;
|
||||
state: string;
|
||||
shortDesc: string;
|
||||
assignedTo: string | null;
|
||||
assignmentGroup: string | null;
|
||||
brand: string | null;
|
||||
market: string | null;
|
||||
businessUnit: string | null;
|
||||
requestedFor: string | null;
|
||||
openedBy: string | null;
|
||||
openedAt: string | null;
|
||||
openedDate: string | null;
|
||||
closedDate: string | null;
|
||||
toDoAt: string | null;
|
||||
inUatAt: string | null;
|
||||
jiraKey: string | null;
|
||||
currencyCode: string | null;
|
||||
ticketYear: number | null;
|
||||
size: string | null;
|
||||
poNumber: string | null; // '' = in finance but no PO yet; null = not in finance
|
||||
invoiced: string | null;
|
||||
dueDate: string | null;
|
||||
stateChangedAt: string | null;
|
||||
stateChangedBy: string | null;
|
||||
lastActivityAt: string | null;
|
||||
lastActivityBy: string | null;
|
||||
lastComment: string | null;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
finalCost: number | null;
|
||||
ttfrMinutes: number | null;
|
||||
clientRespMinutes: number | null;
|
||||
fulfillmentDate: string | null;
|
||||
firstReplyAt: string | null;
|
||||
firstAssignedDate: string | null;
|
||||
updatedAt: string | null;
|
||||
jira: JiraInfo | null;
|
||||
activity: ActivityEntry[];
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: TicketStatus;
|
||||
state?: string;
|
||||
group?: string;
|
||||
assignee?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
total: number;
|
||||
active: number;
|
||||
closed: number;
|
||||
byState: { state: string; count: number }[];
|
||||
byGroup: { group: string; count: number }[];
|
||||
byAssignee: { assignee: string; count: number }[];
|
||||
}
|
||||
Reference in New Issue
Block a user