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 { 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; fxRates: Record; sizeThresholds: Record; norms: Record>; brandColors: Record; snowColors: Record; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, cfg: ForgeConfig): Ticket { const meta = (r.meta ?? {}) as Record; 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, 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 { try { return (JSON.parse(fs.readFileSync(path.join(dir, 'finance.json'), 'utf-8')).rows ?? {}) as Record; } 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 = {}; try { const active = JSON.parse(fs.readFileSync(path.join(dir, 'active_archive.json'), 'utf-8')); jira = (active.jira ?? {}) as Record; for (const raw of active.tickets ?? []) { const t = fromActive(raw as Record, 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>; 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 { 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 { 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; jiraKey?: string | null; } export async function attachJira(items: JiraAttach[]): Promise { 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 { 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): 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']) ?? [], }; }