Init
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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}`));
|
||||
})();
|
||||
Reference in New Issue
Block a user