Initial import of Forge app

This commit is contained in:
2026-08-29 12:30:19 +03:00
commit e637634c59
118 changed files with 17355 additions and 0 deletions
+309
View File
@@ -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: '714 days', hi: 14 },
{ key: '14-31d', label: '1431 days', hi: 31 },
{ key: '1-3m', label: '13 months', hi: 93 },
{ key: '3-6m', label: '36 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;
}
+86
View File
@@ -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
+180
View File
@@ -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
View File
@@ -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']) ?? [],
};
}
+175
View File
@@ -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(),
};
}
+19
View File
@@ -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();
})();
+17
View File
@@ -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();
})();
+57
View File
@@ -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) })),
};
}
+26
View File
@@ -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);
});
});
+110
View File
@@ -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]);
}
+81
View File
@@ -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 }[];
}