443 lines
16 KiB
TypeScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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<string, { x: number; y: number }>();
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 `<g class="edge">
<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" marker-end="url(#arrow)"></line>
<text x="${mx}" y="${my - 5}" text-anchor="middle">${escapeHtml(edge.relationship)}</text>
</g>`;
}).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 `<a href="#node-${encodeURIComponent(node.id)}">
<g class="graph-node ${escapeHtml(node.node_type)}">
<title>${escapeHtml(node.title)} — ${escapeHtml(node.node_type)}</title>
<rect x="${p.x - 100}" y="${p.y - 38}" width="200" height="76" rx="14"></rect>
<text class="node-type" x="${p.x}" y="${p.y - 13}" text-anchor="middle">${escapeHtml(node.node_type)}</text>
<text class="node-title" x="${p.x}" y="${p.y + 10}" text-anchor="middle">${escapeHtml(short)}</text>
</g>
</a>`;
}).join("");
return `<svg class="story-graph" viewBox="0 0 ${width} ${height}" role="img" aria-label="Evidence network for ${escapeHtml(story.title)}">
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"></path></marker></defs>
<g class="thread-centre">
<rect x="${cx - 125}" y="${cy - 48}" width="250" height="96" rx="18"></rect>
<text x="${cx}" y="${cy - 8}" text-anchor="middle">STORY THREAD</text>
<text x="${cx}" y="${cy + 18}" text-anchor="middle">${escapeHtml(String(story.title).slice(0, 38))}</text>
</g>
${edgeSvg}
${nodeSvg}
</svg>`;
}
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);
});