369 lines
15 KiB
TypeScript
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}`));
|
||
|
|
})();
|