Sync human opportunities triage UI
This commit is contained in:
parent
2888ec1f34
commit
679c8c198a
10
src/db.ts
10
src/db.ts
@ -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);
|
||||||
|
|||||||
88
src/index.ts
88
src/index.ts
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user