Sync human opportunities triage UI

This commit is contained in:
Admin 2026-09-29 06:43:14 +00:00
parent 2888ec1f34
commit 679c8c198a
2 changed files with 95 additions and 3 deletions

View File

@ -305,7 +305,11 @@ export async function sourceStatus() {
const result = await pool.query(` const result = await pool.query(`
SELECT s.*, SELECT s.*,
(SELECT COUNT(*)::int FROM signals sig WHERE sig.source_id=s.id) AS signal_count, (SELECT COUNT(*)::int FROM signals sig WHERE sig.source_id=s.id) AS signal_count,
(SELECT COUNT(*)::int FROM opportunities o JOIN signals sig ON sig.id=o.signal_id WHERE sig.source_id=s.id) AS opportunity_count (SELECT COUNT(*)::int
FROM opportunities o
JOIN signals sig ON sig.id=o.signal_id
WHERE sig.source_id=s.id
AND o.status <> 'closed') AS opportunity_count
FROM sources s FROM sources s
ORDER BY s.id ORDER BY s.id
`); `);
@ -317,7 +321,9 @@ export async function listOpportunities(args: { status?: string; since?: string;
const where: string[] = []; const where: string[] = [];
if (args.status) { if (args.status) {
values.push(args.status); values.push(args.status);
where.push(`o.status=$${values.length}`); where.push(`o.status=${values.length}`);
} else {
where.push("o.status <> 'closed'");
} }
if (args.since) { if (args.since) {
values.push(args.since); values.push(args.since);

View File

@ -1,7 +1,10 @@
import express from "express"; import express from "express";
import { import {
acknowledgeAgentMessage, acknowledgeAgentMessage,
getOpportunity,
listAgentMessages, listAgentMessages,
listOpportunities,
markOpportunity,
migrate, migrate,
pool, pool,
seedSources seedSources
@ -112,6 +115,89 @@ app.post("/messages/:id/ack", requireWebAuth, async (req, res) => {
return res.redirect("/"); 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) => { app.get("/", requireWebAuth, async (req, res) => {
const sourceRows = await pool.query( 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.last_synced_at,s.last_error,s.baseline_complete,
@ -153,7 +239,7 @@ a{color:inherit}
button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer} button{margin-top:12px;padding:7px 11px;border:1px solid #9ca3af;border-radius:7px;background:#fff;cursor:pointer}
</style></head><body> </style></head><body>
<header> <header>
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p></div> <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></p></div>
<div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div> <div class="user"><strong>${escapeHtml(identity)}</strong><br><a href="/auth/logout">Sign out</a></div>
</header> </header>
<div class="grid"> <div class="grid">