Files

369 lines
15 KiB
TypeScript

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<string, unknown> => !!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<string, unknown>): 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<string, unknown>))
.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<string, unknown>)
.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<string, unknown>,
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}`));
})();