import 'dotenv/config'; import path from 'node:path'; import express, { type Request, type Response, type NextFunction } from 'express'; import rateLimit from 'express-rate-limit'; import { pool, initDB, upsertTickets, attachJira, type JiraAttach } from './server/db'; import { listTickets, getTicket, getStats } from './server/tickets'; import { resolveToken, createToken, listTokens, revokeToken } from './server/tokens'; import { sessionMiddleware, requireAuth, requireRole, verifyLogin } from './server/auth'; import { listUsers, createUser, deleteUser, isRole, getUserRole, countAdmins } from './server/db'; import { getOverview, getSlaHeatmaps, getConfig, getJiraDurations } from './server/analytics'; import { getInsights } from './server/insights'; import type { Ticket, TicketStatus } from './server/types'; const PORT = Number(process.env.PORT ?? 3000); // Prod: __dirname is /app/dist (compiled) → client/dist is a sibling ('..'). // Dev (tsx index.ts): __dirname is the project root → client/dist is under it ('.'). const CLIENT_DIST = path.join(__dirname, __dirname.endsWith('dist') ? '..' : '.', 'client', 'dist'); if (!process.env.DATABASE_URL) { console.error('FATAL: DATABASE_URL is required (points at the `forge` Postgres database)'); process.exit(1); } const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); app.use(express.json({ limit: '25mb' })); // sync payloads carry activity timelines app.use(sessionMiddleware()); // --- Health ---------------------------------------------------------------- app.get('/healthz', async (_req: Request, res: Response) => { try { await pool.query('SELECT 1'); res.json({ ok: true }); } catch { res.status(503).json({ ok: false }); } }); // --- Auth ------------------------------------------------------------------ const loginLimiter = rateLimit({ windowMs: 15 * 60_000, max: 20, standardHeaders: true, legacyHeaders: false }); app.post('/login', loginLimiter, async (req: Request, res: Response) => { const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; const ok = await verifyLogin(String(username ?? ''), String(password ?? '')); if (!ok) return res.status(401).json({ success: false, error: 'Invalid username or password' }); // Regenerate the session id at the privilege transition (anti session-fixation). req.session.regenerate(err => { if (err) { console.error('session regenerate failed:', err.message); return res.status(500).json({ success: false, error: 'session_error' }); } req.session.user = ok; req.session.save(saveErr => { if (saveErr) { console.error('session save failed:', saveErr.message); return res.status(500).json({ success: false, error: 'session_error' }); } res.json({ success: true, user: ok }); }); }); }); app.post('/logout', (req: Request, res: Response) => { req.session.destroy(() => { res.clearCookie('forge.sid'); res.json({ success: true }); }); }); app.get('/api/me', (req: Request, res: Response) => { res.json({ user: req.session?.user ?? null }); }); // --- Read API (auth-gated) ------------------------------------------------- app.get('/api/tickets', requireAuth, async (req: Request, res: Response) => { try { const q = req.query; const status = q.status === 'active' || q.status === 'closed' ? (q.status as TicketStatus) : undefined; const tickets = await listTickets({ status, state: typeof q.state === 'string' ? q.state : undefined, group: typeof q.group === 'string' ? q.group : undefined, assignee: typeof q.assignee === 'string' ? q.assignee : undefined, q: typeof q.q === 'string' ? q.q : undefined, }); res.json({ tickets, count: tickets.length }); } catch (err) { console.error('GET /api/tickets', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/tickets/:number', requireAuth, async (req: Request, res: Response) => { try { const t = await getTicket(String(req.params.number)); if (!t) return res.status(404).json({ error: 'not_found' }); res.json(t); } catch (err) { console.error('GET /api/tickets/:number', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/stats', requireAuth, async (_req: Request, res: Response) => { try { res.json(await getStats()); } catch (err) { console.error('GET /api/stats', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/analytics/overview', requireRole('pm'), async (_req: Request, res: Response) => { try { res.json(await getOverview()); } catch (err) { console.error('GET /api/analytics/overview', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/analytics/sla', requireRole('pm'), async (_req: Request, res: Response) => { try { res.json({ metrics: await getSlaHeatmaps() }); } catch (err) { console.error('GET /api/analytics/sla', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/analytics/jira-durations', requireRole('pm'), async (_req: Request, res: Response) => { try { res.json(await getJiraDurations()); } catch (err) { console.error('GET /api/analytics/jira-durations', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/insights', requireRole('pm'), async (_req: Request, res: Response) => { try { res.json(await getInsights()); } catch (err) { console.error('GET /api/insights', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.get('/api/config', requireAuth, async (_req: Request, res: Response) => { try { res.json(await getConfig()); } catch (err) { console.error('GET /api/config', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); // --- Token-authed sync ingest (used by the Chrome extension) --------------- const syncLimiter = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }); async function requireToken(req: Request, res: Response, next: NextFunction) { const principal = await resolveToken(req); if (!principal) return res.status(401).json({ error: 'token_invalid' }); next(); } function s(v: unknown): string | null { if (v == null) return null; const str = String(v).trim(); return str === '' ? null : str; } function n(v: unknown): number | null { if (v == null || v === '') return null; const x = Number(v); return Number.isFinite(x) ? x : null; } // Coerce an untrusted activity array into well-typed { kind, t, who } string // entries. Prevents a non-string `t` (which the client's date parser would call // .includes() on) from being persisted and later crashing the render. function sanitizeActivity(v: unknown): Ticket['activity'] { if (!Array.isArray(v)) return []; return v .filter((e): e is Record => !!e && typeof e === 'object') .map(e => ({ kind: String(e.kind ?? 'comment'), t: String(e.t ?? ''), who: String(e.who ?? '') })); } // Coerce an untrusted incoming record into a normalized Ticket. Drops records // without a number. Unknown fields are ignored. function normalizeIncoming(raw: Record): Ticket | null { const number = s(raw.number); if (!number) return null; const status: TicketStatus = raw.status === 'closed' ? 'closed' : 'active'; return { number, status, state: String(raw.state ?? ''), shortDesc: String(raw.shortDesc ?? ''), assignedTo: s(raw.assignedTo), assignmentGroup: s(raw.assignmentGroup), brand: s(raw.brand), market: s(raw.market), businessUnit: s(raw.businessUnit), requestedFor: s(raw.requestedFor), openedBy: s(raw.openedBy), openedAt: s(raw.openedAt), openedDate: s(raw.openedDate) ?? s(raw.openedAt), closedDate: s(raw.closedDate), toDoAt: s(raw.toDoAt), inUatAt: s(raw.inUatAt), jiraKey: s(raw.jiraKey), currencyCode: s(raw.currencyCode), ticketYear: n(raw.ticketYear), size: s(raw.size), poNumber: s(raw.poNumber), invoiced: s(raw.invoiced), dueDate: s(raw.dueDate), stateChangedAt: s(raw.stateChangedAt), stateChangedBy: s(raw.stateChangedBy), lastActivityAt: s(raw.lastActivityAt), lastActivityBy: s(raw.lastActivityBy), lastComment: s(raw.lastComment), description: s(raw.description), link: s(raw.link), finalCost: n(raw.finalCost), ttfrMinutes: n(raw.ttfrMinutes), clientRespMinutes: n(raw.clientRespMinutes), fulfillmentDate: s(raw.fulfillmentDate), firstReplyAt: s(raw.firstReplyAt), firstAssignedDate: s(raw.firstAssignedDate), updatedAt: s(raw.updatedAt), jira: (raw.jira ?? null) as Ticket['jira'], activity: sanitizeActivity(raw.activity), }; } app.post('/api/sync', syncLimiter, requireToken, async (req: Request, res: Response) => { try { const body = req.body as { tickets?: unknown }; if (!Array.isArray(body.tickets)) return res.status(400).json({ error: 'bad_payload' }); const tickets = body.tickets .map(r => normalizeIncoming(r as Record)) .filter((t): t is Ticket => t !== null); if (tickets.length === 0) return res.json({ upserted: 0 }); const upserted = await upsertTickets(tickets); res.json({ upserted }); } catch (err) { console.error('POST /api/sync', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); // Jira enrichment ingest (from the extension's Jira REST collector). Attach-only: // updates only jira/jira_key on existing tickets, keyed by RITM number. app.post('/api/sync/jira', syncLimiter, requireToken, async (req: Request, res: Response) => { try { const body = req.body as { items?: unknown }; if (!Array.isArray(body.items)) return res.status(400).json({ error: 'bad_payload' }); const items: JiraAttach[] = body.items .map(r => r as Record) .filter(r => r && typeof r.number === 'string' && (r.number as string).trim()) .map(r => ({ number: String(r.number).trim(), jira: (r.jira && typeof r.jira === 'object' ? r.jira : {}) as Record, jiraKey: typeof r.jiraKey === 'string' ? r.jiraKey : (typeof (r.jira as { key?: unknown })?.key === 'string' ? (r.jira as { key: string }).key : null), })); if (items.length === 0) return res.json({ matched: 0 }); const matched = await attachJira(items); res.json({ matched, received: items.length }); } catch (err) { console.error('POST /api/sync/jira', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); // --- User management (Project Leadership + Admin) -------------------------- app.get('/api/users', requireRole('lead'), async (_req: Request, res: Response) => { try { res.json({ users: await listUsers() }); } catch (err) { console.error('GET /api/users', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.post('/api/users', requireRole('lead'), async (req: Request, res: Response) => { const { username, password, role } = (req.body ?? {}) as { username?: string; password?: string; role?: string }; const u = String(username ?? '').trim(); const p = String(password ?? ''); if (u.length < 2 || p.length < 6) return res.status(400).json({ error: 'invalid', message: 'Username ≥2 and password ≥6 chars required' }); if (!isRole(role)) return res.status(400).json({ error: 'invalid_role' }); // Only an admin may create another admin (leadership tops out at 'lead'). if (role === 'admin' && req.session.user?.role !== 'admin') return res.status(403).json({ error: 'forbidden', message: 'Only an admin can create an admin' }); try { const result = await createUser(u, p, role); if (result === 'exists') return res.status(409).json({ error: 'exists', message: 'Username already taken' }); res.json({ user: result }); } catch (err) { console.error('POST /api/users', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.delete('/api/users/:username', requireRole('lead'), async (req: Request, res: Response) => { const target = String(req.params.username); const actor = req.session.user!; if (target === actor.username) return res.status(400).json({ error: 'self', message: 'You cannot delete your own account' }); try { const targetRole = await getUserRole(target); if (!targetRole) return res.status(404).json({ error: 'not_found' }); // Only an admin may remove an admin, and never the last admin (would lock everyone out). if (targetRole === 'admin') { if (actor.role !== 'admin') return res.status(403).json({ error: 'forbidden', message: 'Only an admin can remove an admin' }); if (await countAdmins() <= 1) return res.status(400).json({ error: 'last_admin', message: 'Cannot delete the last admin' }); } await deleteUser(target); res.json({ success: true }); } catch (err) { console.error('DELETE /api/users', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); // --- API tokens (Admin only) ----------------------------------------------- app.get('/api/tokens', requireRole('admin'), async (_req: Request, res: Response) => { try { res.json({ tokens: await listTokens() }); } catch (err) { console.error('GET /api/tokens', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.post('/api/tokens', requireRole('admin'), async (req: Request, res: Response) => { try { const label = typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : 'sync-extension'; const raw = await createToken(label); res.json({ token: raw, label }); } catch (err) { console.error('POST /api/tokens', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); app.post('/api/tokens/:id/revoke', requireRole('admin'), async (req: Request, res: Response) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'invalid_id' }); try { await revokeToken(id); res.json({ success: true }); } catch (err) { console.error('POST /api/tokens/:id/revoke', (err as Error).message); res.status(500).json({ error: 'server_error' }); } }); // --- Static client + SPA fallback ----------------------------------------- app.use(express.static(CLIENT_DIST)); app.get('*', (req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith('/api/')) return next(); res.sendFile(path.join(CLIENT_DIST, 'index.html'), err => { if (err) next(); }); }); (async () => { try { await initDB(); console.log('DB ready (forge)'); } catch (err) { console.error('initDB failed:', (err as Error).message); process.exit(1); } app.listen(PORT, () => console.log(`FORGE listening on http://localhost:${PORT}`)); })();