diff --git a/src/index.ts b/src/index.ts
index ddf623b..f59db72 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,7 @@ import {
import { handleMcp } from "./mcp.js";
import { SOURCES } from "./sources.js";
import { syncAll } from "./sync.js";
+import { renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory } from "./web.js";
const app = express();
app.set("trust proxy", true);
@@ -186,81 +187,19 @@ app.get("/stories/:id", requireWebAuth, async (req, res) => {
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(`
-
-${escapeHtml(story.title)} — Story Research
-
-
-
-
Status: ${escapeHtml(story.status)} ·
-
Nodes: ${nodes.length} ·
-
Edges: ${edges.length}
-
-${["researching","review","develop","closed"].map(s=>``).join("")}
-
-
-
-
Mandatory counter-evidence pass:
-${counter ? `${escapeHtml(counter.result)} · sources checked: ${escapeHtml((counter.sources_checked ?? []).join(", "))}
${escapeHtml(counter.summary)}
` : "not completed"}
-
-${storyGraphSvg(story)}
-Research nodes
-
-${nodes.map((n:any)=>`
-
${escapeHtml(n.node_type)}
-
${escapeHtml(n.title)}
-
${escapeHtml(n.summary)}
-
${n.provenance_source_id ? `source ${escapeHtml(n.provenance_source_id)} · ` : ""}${n.confidence != null ? `confidence ${escapeHtml(n.confidence)} · ` : ""}${escapeHtml(n.created_by_name)}
-${n.evidence_url ? `
Open evidence ↗
` : ""}
-
`).join("")}
-
-Relationships
-${edges.length ? edges.map((e:any)=>`${escapeHtml(e.relationship)}${escapeHtml(e.from_node_id.slice(0,8))} →
${escapeHtml(e.to_node_id.slice(0,8))}${escapeHtml(e.reason)}
`).join("") : 'No graph edges yet.
'}
-`);
+ const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
+ return res.type("html").send(renderStory({ story, identity }));
});
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";
+ const rows = await listStoryThreads({ limit: 100 });
+ const stories = (await Promise.all(
+ rows.map((row: any) => getStoryThread(String(row.id)))
+ )).filter(Boolean) as any[];
- res.type("html").send(`
-
-Story Threads — Media Intelligence
-
-
-${stories.length ? stories.map((s:any)=>`
-
${escapeHtml(s.title)}
-
${escapeHtml(s.question)}
-
${escapeHtml(s.status)} · ${s.node_count} nodes · ${s.edge_count} edges · counter-evidence: ${escapeHtml(s.counter_evidence_result ?? "pending")}
-
`).join("") : 'No story threads yet.
'}
-`);
+ const user = (req as any).user ?? {};
+ const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
+ return res.type("html").send(renderStories({ stories, identity }));
});
app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
@@ -284,39 +223,8 @@ app.get("/opportunities/:id", requireWebAuth, async (req, res) => {
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(`
-
-${escapeHtml(opportunity.title)} — Media Intelligence
-
-
-
-
${escapeHtml(opportunity.source_id)} · ${escapeHtml(nzTime(opportunity.published_at || opportunity.detected_at))}
-
${escapeHtml(opportunity.summary)}
-
Status: ${escapeHtml(opportunity.status)} · Score: ${escapeHtml(opportunity.score)}
-
Why surfaced: ${escapeHtml((opportunity.reasons ?? []).join(" · "))}
-
Open original source ↗
-
-${["watch","investigate","develop","ignore","closed"].map(status=>``).join("")}
-
-
-Evidence
-${evidence.length ? evidence.map((e:any)=>`${escapeHtml(nzTime(e.retrieved_at))} · ${escapeHtml(e.content_hash?.slice(0,12))}
${escapeHtml(e.excerpt)}Source ↗ `).join("") : 'No evidence stored.
'}
-Notes
-${notes.length ? notes.map((n:any)=>`${escapeHtml(nzTime(n.created_at))}
${escapeHtml(n.note)}
`).join("") : 'No notes yet.
'}
-`);
+ const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
+ return res.type("html").send(renderOpportunity({ opportunity, identity }));
});
app.get("/opportunities", requireWebAuth, async (req, res) => {
@@ -324,44 +232,21 @@ app.get("/opportunities", requireWebAuth, async (req, res) => {
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(`
-
-Opportunities — Media Intelligence
-
-
-
-
Active
-${["new","watch","investigate","develop","ignore","closed"].map(s=>`
${s}`).join("")}
-
-${opportunities.length ? opportunities.map((o:any)=>`
-
${escapeHtml(o.title)}
-
${escapeHtml(o.source_id)} · ${escapeHtml(nzTime(o.published_at || o.detected_at))} · status ${escapeHtml(o.status)} · score ${escapeHtml(o.score)}
-
${escapeHtml(o.summary)}
-
${escapeHtml((o.reasons ?? []).join(" · "))}
-
-${["watch","investigate","develop","ignore"].map(s=>``).join("")}
-
-
`).join("") : 'No opportunities in this view.
'}
-`);
+ const user = (req as any).user ?? {};
+ const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
+ return res.type("html").send(renderOpportunities({ opportunities, identity, status }));
});
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,
+ `SELECT s.id,s.name,s.source_type,s.source_class,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.last_synced_at,s.last_error,s.baseline_complete
+ GROUP BY s.id,s.name,s.source_type,s.source_class,s.publisher,s.authority_tier,
+ s.last_synced_at,s.last_error,s.baseline_complete
ORDER BY s.id`
);
const counts = await pool.query(`
@@ -373,56 +258,21 @@ app.get("/", requireWebAuth, async (req, res) => {
(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";
+ const storyRows = await listStoryThreads({ limit: 20 });
+ const stories = (await Promise.all(
+ storyRows.map((row: any) => getStoryThread(String(row.id)))
+ )).filter(Boolean) as any[];
- res.type("html").send(`
-
-Media Intelligence
-
-
-
-
${c.signals ?? 0}signals
-
${c.opportunities ?? 0}opportunities
-
${c.new_opportunities ?? 0}new
-
${c.unread_messages ?? 0}agent messages
-
${c.story_threads ?? 0}story threads
-
-Agent Activity
-${messages.length
- ? messages.map((m:any)=>`
-
-
${escapeHtml(m.title)}
${escapeHtml(m.kind)} · ${escapeHtml(m.severity)}
-
${escapeHtml(nzTime(m.created_at))}
-
-
${escapeHtml(m.body)}
-
From ${escapeHtml(m.actor_name)} via ${escapeHtml(m.actor_client_id)}${m.related_type ? ` · ${escapeHtml(m.related_type)} ${escapeHtml(m.related_id ?? "")}` : ""}
-
-
`).join("")
- : 'No unread agent messages.
'}
-Sources
-${sourceRows.rows.map((s:any)=>`${s.name}
${s.id} · ${s.baseline_complete ? 'baseline ready' : 'awaiting baseline'}
${s.signal_count} stored signals
${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}
`).join("")}
-MCP endpoint: /api/mcp — OAuth protected separately from this browser session.
-`);
+ const user = (req as any).user ?? {};
+ const identity = String(user.name || user.preferred_username || user.email || "Signed-in user");
+
+ return res.type("html").send(renderDashboard({
+ identity,
+ counts: counts.rows[0] ?? {},
+ messages,
+ sources: sourceRows.rows,
+ stories
+ }));
});
async function start() {