476 lines
24 KiB
TypeScript
476 lines
24 KiB
TypeScript
import express from "express";
|
|
import {
|
|
acknowledgeAgentMessage,
|
|
enrichStoryEvidenceDocuments,
|
|
getOpportunity,
|
|
getStoryThread,
|
|
listAgentMessages,
|
|
listOpportunities,
|
|
listStoryThreads,
|
|
markOpportunity,
|
|
markStoryThreadStatus,
|
|
migrate,
|
|
pool,
|
|
seedInitialStoryThread,
|
|
seedSources
|
|
} from "./db.js";
|
|
import {
|
|
beginWebLogin,
|
|
finishWebLogin,
|
|
logoutWeb,
|
|
protectedResourceMetadata,
|
|
requireMcpAuth,
|
|
requireWebAuth
|
|
} from "./auth.js";
|
|
import { handleMcp } from "./mcp.js";
|
|
import { SOURCES } from "./sources.js";
|
|
import { syncAll } from "./sync.js";
|
|
|
|
const app = express();
|
|
app.set("trust proxy", true);
|
|
app.use(express.json({ limit: "2mb" }));
|
|
app.use(express.urlencoded({ extended: false }));
|
|
|
|
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(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.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.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/status", requireWebAuth, 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 = user.name || user.preferred_username || user.email || "Signed-in user";
|
|
const nodes = Array.isArray(story.nodes) ? story.nodes : [];
|
|
const edges = Array.isArray(story.edges) ? story.edges : [];
|
|
const checks = Array.isArray(story.research_checks) ? story.research_checks : [];
|
|
const counter = checks.find((c:any)=>c.check_type === "counter_evidence");
|
|
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${escapeHtml(story.title)} — Story Research</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:1180px;margin:32px auto;padding:0 20px;color:#1f2937}
|
|
header{display:flex;justify-content:space-between;gap:18px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
|
|
.meta{font-size:.86rem;color:#6b7280}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}
|
|
button{padding:8px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
|
|
.graph-wrap{overflow:auto;border:1px solid #d1d5db;border-radius:14px;background:#fafafa;margin:18px 0}
|
|
.story-graph{display:block;min-width:900px;width:100%;height:auto}.edge line{stroke:#9ca3af;stroke-width:2}.edge text{font-size:12px;fill:#4b5563;paint-order:stroke;stroke:#fafafa;stroke-width:5px}
|
|
#arrow path{fill:#9ca3af}.thread-centre rect{fill:#111827}.thread-centre text{fill:#fff;font-size:13px}.graph-node rect{fill:#fff;stroke:#9ca3af;stroke-width:2}
|
|
.graph-node.evidence rect,.graph-node.actual rect{fill:#eff6ff;stroke:#2563eb}.graph-node.claim rect{fill:#ecfdf5;stroke:#059669}.graph-node.counterclaim rect{fill:#fef2f2;stroke:#dc2626}
|
|
.graph-node.question rect{fill:#fff7ed;stroke:#ea580c}.graph-node.forecast rect,.graph-node.trend rect{fill:#faf5ff;stroke:#9333ea}.graph-node.context rect{fill:#f8fafc;stroke:#64748b}
|
|
.node-type{font-size:10px;text-transform:uppercase;fill:#6b7280}.node-title{font-size:12px;font-weight:650;fill:#111827}
|
|
.node-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}.node-card{scroll-margin-top:20px}
|
|
.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
|
|
.check-ok{border-left:5px solid #059669}.check-pending{border-left:5px solid #d97706}
|
|
</style></head><body>
|
|
<header><div><a href="/stories">← Story Threads</a><h1>${escapeHtml(story.title)}</h1><p>${escapeHtml(story.question)}</p></div>
|
|
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
|
<div class="card">
|
|
<strong>Status:</strong> ${escapeHtml(story.status)} ·
|
|
<strong>Nodes:</strong> ${nodes.length} ·
|
|
<strong>Edges:</strong> ${edges.length}
|
|
<div class="toolbar">
|
|
${["researching","review","develop","closed"].map(s=>`<form method="post" action="/stories/${encodeURIComponent(story.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
|
|
</div>
|
|
</div>
|
|
<div class="card ${counter ? 'check-ok' : 'check-pending'}">
|
|
<strong>Mandatory counter-evidence pass:</strong>
|
|
${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}<p>${escapeHtml(counter.summary)}</p>` : "not completed"}
|
|
</div>
|
|
<div class="graph-wrap">${storyGraphSvg(story)}</div>
|
|
<h2>Research nodes</h2>
|
|
<div class="node-list">
|
|
${nodes.map((n:any)=>`<div class="card node-card" id="node-${escapeHtml(n.id)}">
|
|
<span class="badge">${escapeHtml(n.node_type)}</span>
|
|
<h3>${escapeHtml(n.title)}</h3>
|
|
<p>${escapeHtml(n.summary)}</p>
|
|
<div class="meta">${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}</div>
|
|
${n.evidence_url ? `<p><a href="${escapeHtml(n.evidence_url)}" target="_blank" rel="noreferrer">Open evidence ↗</a></p>` : ""}
|
|
</div>`).join("")}
|
|
</div>
|
|
<h2>Relationships</h2>
|
|
${edges.length ? edges.map((e:any)=>`<div class="card"><strong>${escapeHtml(e.relationship)}</strong><br><code>${escapeHtml(e.from_node_id.slice(0,8))}</code> → <code>${escapeHtml(e.to_node_id.slice(0,8))}</code><p>${escapeHtml(e.reason)}</p></div>`).join("") : '<div class="card meta">No graph edges yet.</div>'}
|
|
</body></html>`);
|
|
});
|
|
|
|
app.get("/stories", requireWebAuth, async (req, res) => {
|
|
const stories = await listStoryThreads({ limit: 100 });
|
|
const user = (req as any).user ?? {};
|
|
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
|
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Story Threads — Media Intelligence</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
|
|
header{display:flex;justify-content:space-between;gap:20px}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}.meta{font-size:.86rem;color:#6b7280}
|
|
.title{font-size:1.08rem;font-weight:700;text-decoration:none}a{color:inherit}.badge{display:inline-block;padding:3px 7px;border-radius:999px;background:#f3f4f6;font-size:.76rem}
|
|
</style></head><body>
|
|
<header><div><a href="/">← Dashboard</a><h1>360° Story Threads</h1><p>Evidence networks that require an explicit counter-evidence pass before editorial review.</p></div>
|
|
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
|
${stories.length ? stories.map((s:any)=>`<div class="card">
|
|
<a class="title" href="/stories/${encodeURIComponent(s.id)}">${escapeHtml(s.title)}</a>
|
|
<p>${escapeHtml(s.question)}</p>
|
|
<div class="meta"><span class="badge">${escapeHtml(s.status)}</span> · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}</div>
|
|
</div>`).join("") : '<div class="card meta">No story threads yet.</div>'}
|
|
</body></html>`);
|
|
});
|
|
|
|
app.post("/messages/:id/ack", requireWebAuth, 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, 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("/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 = user.name || user.preferred_username || user.email || "Signed-in user";
|
|
const evidence = Array.isArray(opportunity.evidence) ? opportunity.evidence : [];
|
|
const notes = Array.isArray(opportunity.notes) ? opportunity.notes : [];
|
|
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${escapeHtml(opportunity.title)} — Media Intelligence</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:960px;margin:36px auto;padding:0 20px;color:#1f2937}
|
|
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}
|
|
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:14px 0}
|
|
.meta{font-size:.88rem;color:#6b7280}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}
|
|
button{padding:8px 12px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
|
|
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}a{color:inherit}
|
|
pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f9fafb;padding:12px;border-radius:8px}
|
|
</style></head><body>
|
|
<header><div><a href="/opportunities">← Opportunities</a><h1>${escapeHtml(opportunity.title)}</h1></div>
|
|
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
|
<div class="card">
|
|
<div class="meta">${escapeHtml(opportunity.source_id)} · ${escapeHtml(nzTime(opportunity.published_at || opportunity.detected_at))}</div>
|
|
<p>${escapeHtml(opportunity.summary)}</p>
|
|
<p><strong>Status:</strong> ${escapeHtml(opportunity.status)} · <strong>Score:</strong> ${escapeHtml(opportunity.score)}</p>
|
|
<p><strong>Why surfaced:</strong> ${escapeHtml((opportunity.reasons ?? []).join(" · "))}</p>
|
|
<p><a href="${escapeHtml(opportunity.url)}" target="_blank" rel="noreferrer">Open original source ↗</a></p>
|
|
<div class="actions">
|
|
${["watch","investigate","develop","ignore","closed"].map(status=>`<form method="post" action="/opportunities/${encodeURIComponent(opportunity.id)}/status"><input type="hidden" name="status" value="${status}"><input type="hidden" name="returnTo" value="detail"><button type="submit">${status}</button></form>`).join("")}
|
|
</div>
|
|
</div>
|
|
<h2>Evidence</h2>
|
|
${evidence.length ? evidence.map((e:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(e.retrieved_at))} · <code>${escapeHtml(e.content_hash?.slice(0,12))}</code></div><pre>${escapeHtml(e.excerpt)}</pre><a href="${escapeHtml(e.source_url)}" target="_blank" rel="noreferrer">Source ↗</a></div>`).join("") : '<div class="card meta">No evidence stored.</div>'}
|
|
<h2>Notes</h2>
|
|
${notes.length ? notes.map((n:any)=>`<div class="card"><div class="meta">${escapeHtml(nzTime(n.created_at))}</div><p>${escapeHtml(n.note)}</p></div>`).join("") : '<div class="card meta">No notes yet.</div>'}
|
|
</body></html>`);
|
|
});
|
|
|
|
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 = user.name || user.preferred_username || user.email || "Signed-in user";
|
|
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Opportunities — Media Intelligence</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:1040px;margin:36px auto;padding:0 20px;color:#1f2937}
|
|
header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.card{border:1px solid #d1d5db;border-radius:12px;padding:16px;margin:12px 0}
|
|
.meta{font-size:.86rem;color:#6b7280}.filters{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0}.filters a{padding:7px 10px;border:1px solid #d1d5db;border-radius:8px;text-decoration:none}
|
|
.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}button{padding:7px 10px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}a{color:inherit}
|
|
.title{font-size:1.05rem;font-weight:700;text-decoration:none}
|
|
</style></head><body>
|
|
<header><div><a href="/">← Dashboard</a><h1>Media Opportunities</h1><p>Human triage queue. No release drafting or distribution happens here.</p></div>
|
|
<div class="meta">${escapeHtml(identity)} · <a href="/auth/logout">Sign out</a></div></header>
|
|
<div class="filters">
|
|
<a href="/opportunities">Active</a>
|
|
${["new","watch","investigate","develop","ignore","closed"].map(s=>`<a href="/opportunities?status=${s}">${s}</a>`).join("")}
|
|
</div>
|
|
${opportunities.length ? opportunities.map((o:any)=>`<div class="card">
|
|
<a class="title" href="/opportunities/${encodeURIComponent(o.id)}">${escapeHtml(o.title)}</a>
|
|
<div class="meta">${escapeHtml(o.source_id)} · ${escapeHtml(nzTime(o.published_at || o.detected_at))} · status ${escapeHtml(o.status)} · score ${escapeHtml(o.score)}</div>
|
|
<p>${escapeHtml(o.summary)}</p>
|
|
<div class="meta">${escapeHtml((o.reasons ?? []).join(" · "))}</div>
|
|
<div class="actions">
|
|
${["watch","investigate","develop","ignore"].map(s=>`<form method="post" action="/opportunities/${encodeURIComponent(o.id)}/status"><input type="hidden" name="status" value="${s}"><button type="submit">${s}</button></form>`).join("")}
|
|
</div>
|
|
</div>`).join("") : '<div class="card meta">No opportunities in this view.</div>'}
|
|
</body></html>`);
|
|
});
|
|
|
|
app.get("/", requireWebAuth, async (req, res) => {
|
|
const sourceRows = await pool.query(
|
|
`SELECT s.id,s.name,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.last_synced_at,s.last_error,s.baseline_complete
|
|
ORDER BY s.id`
|
|
);
|
|
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 c = counts.rows[0] ?? {};
|
|
const user = (req as any).user ?? {};
|
|
const identity = user.name || user.preferred_username || user.email || "Signed-in user";
|
|
|
|
res.type("html").send(`<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Media Intelligence</title>
|
|
<style>
|
|
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:40px auto;padding:0 20px;color:#1f2937}
|
|
header{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:12px 0}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
|
strong.big{font-size:2rem;display:block}
|
|
.ok{color:#166534}.bad{color:#991b1b}
|
|
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
|
|
a{color:inherit}
|
|
.user{font-size:.9rem;color:#4b5563;text-align:right}
|
|
.message{border-left-width:5px}.message.notice{border-left-color:#2563eb}.message.warning{border-left-color:#d97706}.message.error{border-left-color:#b91c1c}
|
|
.message-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
|
.message-body{white-space:pre-wrap;line-height:1.45}
|
|
.meta{font-size:.84rem;color:#6b7280}
|
|
button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
|
|
</style></head><body>
|
|
<header>
|
|
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p><p><a href="/opportunities">Open Opportunities →</a> · <a href="/stories">Open 360° Story Threads →</a></p></div>
|
|
<div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div>
|
|
</header>
|
|
<div class="grid">
|
|
<div class="card"><strong class="big">${c.signals ?? 0}</strong>signals</div>
|
|
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
|
|
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
|
|
<div class="card"><strong class="big">${c.unread_messages ?? 0}</strong>agent messages</div>
|
|
<div class="card"><strong class="big">${c.story_threads ?? 0}</strong>story threads</div>
|
|
</div>
|
|
<h2>Agent Activity</h2>
|
|
${messages.length
|
|
? messages.map((m:any)=>`<div class="card message ${escapeHtml(m.severity)}">
|
|
<div class="message-head">
|
|
<div><strong>${escapeHtml(m.title)}</strong><br><span class="meta">${escapeHtml(m.kind)} · ${escapeHtml(m.severity)}</span></div>
|
|
<div class="meta">${escapeHtml(nzTime(m.created_at))}</div>
|
|
</div>
|
|
<p class="message-body">${escapeHtml(m.body)}</p>
|
|
<div class="meta">From ${escapeHtml(m.actor_name)} via <code>${escapeHtml(m.actor_client_id)}</code>${m.related_type ? ` · ${escapeHtml(m.related_type)} <code>${escapeHtml(m.related_id ?? "")}</code>` : ""}</div>
|
|
<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack"><button type="submit">Acknowledge</button></form>
|
|
</div>`).join("")
|
|
: '<div class="card"><span class="meta">No unread agent messages.</span></div>'}
|
|
<h2>Sources</h2>
|
|
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code> · ${s.baseline_complete ? 'baseline ready' : 'awaiting baseline'}<br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
|
|
<p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p>
|
|
</body></html>`);
|
|
});
|
|
|
|
async function start() {
|
|
await migrate();
|
|
await seedSources(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);
|
|
});
|