443 lines
16 KiB
TypeScript
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, "<")
|
|
.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<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);
|
|
});
|