import express from "express"; import { acknowledgeAgentMessage, addStoryNode, enrichStoryEvidenceDocuments, getOpportunity, getStoryThread, listAgentMessages, listOpportunities, listStoryThreads, markOpportunity, markStoryThreadStatus, postAgentMessage, setStoryNodeReviewState, migrate, pool, seedInitialStoryThread, seedSources, seedSourceTrustPolicy, listSourceTrustPolicy, upsertSourceTrustEntry } from "./db.js"; import { beginWebLogin, finishWebLogin, logoutWeb, protectedResourceMetadata, requireMcpAuth, requireWebAuth, requireWebRole, webMediaRole } from "./auth.js"; import { handleMcp } from "./mcp.js"; import { SOURCES } from "./sources.js"; import { syncAll } from "./sync.js"; import { serveNewsroomEvents } from "./realtime.js"; import { renderAboutNewsroom, renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory, renderTrustPolicy } from "./web.js"; const app = express(); app.set("trust proxy", true); app.use(express.json({ limit: "2mb" })); app.use(express.urlencoded({ extended: false })); function webActor(req: express.Request) { const user = (req as any).user ?? {}; return { sub: String(user.sub ?? "web-user"), name: String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"), clientId: "media-intelligence-web" }; } function escapeHtml(value: unknown) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function nzTime(value: unknown) { if (!value) return ""; try { return new Intl.DateTimeFormat("en-NZ", { timeZone: "Pacific/Auckland", dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value))); } catch { return String(value); } } function storyGraphSvg(story: any) { const nodes = Array.isArray(story.nodes) ? story.nodes : []; const edges = Array.isArray(story.edges) ? story.edges : []; const width = 1000; const height = 640; const cx = width / 2; const cy = height / 2 + 20; const rx = 390; const ry = 235; const positions = new Map(); nodes.forEach((node: any, index: number) => { const angle = -Math.PI / 2 + (Math.PI * 2 * index) / Math.max(nodes.length, 1); positions.set(node.id, { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry }); }); const edgeSvg = edges.map((edge: any) => { const from = positions.get(edge.from_node_id); const to = positions.get(edge.to_node_id); if (!from || !to) return ""; const mx = (from.x + to.x) / 2; const my = (from.y + to.y) / 2; return ` ${escapeHtml(edge.relationship)} `; }).join(""); const nodeSvg = nodes.map((node: any) => { const p = positions.get(node.id)!; const title = String(node.title ?? ""); const short = title.length > 36 ? title.slice(0, 33) + "…" : title; return ` ${escapeHtml(node.title)} — ${escapeHtml(node.node_type)} ${escapeHtml(node.node_type)} ${escapeHtml(short)} `; }).join(""); return ` STORY THREAD ${escapeHtml(String(story.title).slice(0, 38))} ${edgeSvg} ${nodeSvg} `; } app.get("/health", async (_req, res) => { try { await pool.query("SELECT 1"); res.json({ ok: true, service: "media-intelligence", version: "0.1.0" }); } catch (error) { res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) }); } }); app.get("/ready", async (_req, res) => { try { const result = await pool.query( `SELECT s.id, s.name, s.last_synced_at, s.last_error, COUNT(DISTINCT sig.external_id)::int AS signal_count FROM sources s LEFT JOIN signals sig ON sig.source_id=s.id GROUP BY s.id,s.name,s.last_synced_at,s.last_error,s.baseline_complete ORDER BY s.id` ); const missing = result.rows .filter((row: any) => Number(row.signal_count) < 1) .map((row: any) => row.id); const errored = result.rows .filter((row: any) => row.last_error) .map((row: any) => row.id); if (missing.length || errored.length) { return res.status(503).json({ ok: false, missing_sources: missing, errored_sources: errored, sources: result.rows }); } return res.json({ ok: true, sources: result.rows }); } catch (error) { return res.status(503).json({ ok: false, error: error instanceof Error ? error.message : String(error) }); } }); app.get("/.well-known/oauth-protected-resource", (req, res) => res.json(protectedResourceMetadata(req)) ); app.get("/.well-known/oauth-protected-resource/api/mcp", (req, res) => res.json(protectedResourceMetadata(req)) ); app.get("/auth/login", beginWebLogin); app.get("/auth/callback", finishWebLogin); app.get("/auth/logout", logoutWeb); app.get("/api/events", requireWebAuth, serveNewsroomEvents); app.post("/api/mcp", requireMcpAuth, handleMcp); app.get("/api/mcp", requireMcpAuth, (_req, res) => { res.status(405).setHeader("Allow", "POST").json({ error: "Use POST for MCP JSON-RPC requests" }); }); app.post("/stories/:id/nodes", requireWebAuth, requireWebRole("editor"), async (req, res) => { const threadId = String(req.params.id); try { await addStoryNode(threadId, { nodeType: String(req.body.node_type ?? "context"), title: String(req.body.title ?? ""), summary: String(req.body.summary ?? "") }, webActor(req)); return res.redirect(`/stories/${encodeURIComponent(threadId)}`); } catch (error) { return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); } }); app.post("/stories/:id/nodes/:nodeId/review", requireWebAuth, requireWebRole("editor"), async (req, res) => { const threadId = String(req.params.id); try { await setStoryNodeReviewState( threadId, String(req.params.nodeId), String(req.body.review_state ?? "active"), webActor(req) ); return res.redirect(`/stories/${encodeURIComponent(threadId)}`); } catch (error) { return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); } }); app.post("/stories/:id/request-research", requireWebAuth, requireWebRole("editor"), async (req, res) => { const threadId = String(req.params.id); try { await postAgentMessage({ title: `Research request: ${String(req.body.title ?? "").trim()}`, body: String(req.body.body ?? "").trim(), severity: "notice", kind: "request", relatedType: "story", relatedId: threadId }, webActor(req)); return res.redirect(`/stories/${encodeURIComponent(threadId)}`); } catch (error) { return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); } }); app.post("/stories/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); try { await markStoryThreadStatus(String(req.params.id), String(req.body.status ?? ""), `web:${identity}`); return res.redirect(`/stories/${encodeURIComponent(String(req.params.id))}`); } catch (error) { return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); } }); app.get("/stories/:id", requireWebAuth, async (req, res) => { const story = await getStoryThread(String(req.params.id)); if (!story) return res.status(404).send("Story thread not found"); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderStory({ story, identity, role })); }); app.get("/stories", requireWebAuth, async (req, res) => { const rows = await listStoryThreads({ limit: 100 }); const stories = (await Promise.all( rows.map((row: any) => getStoryThread(String(row.id))) )).filter(Boolean) as any[]; const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderStories({ stories, identity, role })); }); app.post("/messages/:id/ack", requireWebAuth, requireWebRole("editor"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String( user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user" ); await acknowledgeAgentMessage(String(req.params.id), `web:${identity}`); return res.redirect("/"); }); app.post("/opportunities/:id/status", requireWebAuth, requireWebRole("editor"), async (req, res) => { const id = String(req.params.id); const status = String(req.body.status ?? ""); await markOpportunity(id, status); return res.redirect(req.body.returnTo === "detail" ? `/opportunities/${encodeURIComponent(id)}` : "/opportunities"); }); app.get("/about", requireWebAuth, async (req, res) => { const [sources, trustEntries] = await Promise.all([ pool.query("SELECT * FROM sources ORDER BY id"), listSourceTrustPolicy() ]); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderAboutNewsroom({ identity, role, sources: sources.rows, trustEntries })); }); app.get("/trust", requireWebAuth, async (req, res) => { const entries = await listSourceTrustPolicy(); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderTrustPolicy({ entries, identity, role })); }); app.post("/trust", requireWebAuth, requireWebRole("admin"), async (req, res) => { const user = (req as any).user ?? {}; const identity = String(user.preferred_username ?? user.email ?? user.name ?? user.sub ?? "web-user"); try { await upsertSourceTrustEntry({ domain: String(req.body.domain ?? ""), name: String(req.body.name ?? ""), trustWeight: Number(req.body.trust_weight), independenceGroup: String(req.body.independence_group ?? ""), sourceClass: String(req.body.source_class ?? "other"), notes: String(req.body.notes ?? ""), enabled: String(req.body.enabled ?? "true") === "true" }, `web:${identity}`); return res.redirect("/trust"); } catch (error) { return res.status(400).type("text").send(error instanceof Error ? error.message : String(error)); } }); app.get("/opportunities/:id", requireWebAuth, async (req, res) => { const opportunity = await getOpportunity(String(req.params.id)); if (!opportunity) return res.status(404).send("Opportunity not found"); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderOpportunity({ opportunity, identity, role })); }); app.get("/opportunities", requireWebAuth, async (req, res) => { const requested = String(req.query.status ?? ""); const valid = new Set(["new","watch","investigate","develop","ignore","closed"]); const status = valid.has(requested) ? requested : undefined; const opportunities = await listOpportunities({ status, limit: 100 }); const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderOpportunities({ opportunities, identity, status, role })); }); app.get("/", requireWebAuth, async (req, res) => { const sourceRows = await pool.query( `SELECT s.id,s.name,s.source_type,s.source_class,s.source_category,s.publisher,s.authority_tier, s.last_synced_at,s.last_error,s.baseline_complete, COUNT(sig.id)::int AS signal_count FROM sources s LEFT JOIN signals sig ON sig.source_id=s.id GROUP BY s.id,s.name,s.source_type,s.source_class,s.source_category,s.publisher,s.authority_tier, s.last_synced_at,s.last_error,s.baseline_complete ORDER BY s.source_category,s.name` ); const counts = await pool.query(` SELECT (SELECT COUNT(*) FROM signals) AS signals, (SELECT COUNT(*) FROM opportunities WHERE status <> 'closed') AS opportunities, (SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities, (SELECT COUNT(*) FROM agent_messages WHERE acknowledged_at IS NULL) AS unread_messages, (SELECT COUNT(*) FROM story_threads WHERE status <> 'closed') AS story_threads `); const messages = await listAgentMessages({ acknowledged: false, limit: 10 }); const storyRows = await listStoryThreads({ limit: 20 }); const stories = (await Promise.all( storyRows.map((row: any) => getStoryThread(String(row.id))) )).filter(Boolean) as any[]; const user = (req as any).user ?? {}; const identity = String(user.name || user.preferred_username || user.email || "Signed-in user"); const role = webMediaRole(req); return res.type("html").send(renderDashboard({ identity, role, counts: counts.rows[0] ?? {}, messages, sources: sourceRows.rows, stories })); }); async function start() { await migrate(); await seedSources(SOURCES); await seedSourceTrustPolicy(SOURCES); await seedInitialStoryThread(); const port = Number(process.env.PORT ?? 3000); app.listen(port, "0.0.0.0", () => { console.log(JSON.stringify({ event: "server_started", port, version: "0.1.0" })); }); const intervalMinutes = Math.max( Number(process.env.SYNC_INTERVAL_MINUTES ?? 15), 5 ); setTimeout( () => enrichStoryEvidenceDocuments() .then((result) => console.log(JSON.stringify({ event: "story_evidence_enrichment", result })) ) .catch((error) => console.error(error)), 2500 ); setTimeout( () => syncAll() .then((r) => console.log(JSON.stringify({ event: "initial_sync", result: r })) ) .catch((e) => console.error(e)), 5000 ); setInterval( () => syncAll() .then((r) => console.log(JSON.stringify({ event: "scheduled_sync", result: r })) ) .catch((e) => console.error(e)), intervalMinutes * 60_000 ); } start().catch((error) => { console.error(error); process.exit(1); });