From 679c8c198ab8ae73c05b6edd5d161a770080093c Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 29 Sep 2026 06:43:14 +0000 Subject: [PATCH] Sync human opportunities triage UI --- src/db.ts | 10 ++++-- src/index.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/db.ts b/src/db.ts index d4f1d7b..f4b0be3 100644 --- a/src/db.ts +++ b/src/db.ts @@ -305,7 +305,11 @@ export async function sourceStatus() { const result = await pool.query(` SELECT s.*, (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 ORDER BY s.id `); @@ -317,7 +321,9 @@ export async function listOpportunities(args: { status?: string; since?: string; const where: string[] = []; if (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) { values.push(args.since); diff --git a/src/index.ts b/src/index.ts index fe9ee1e..af0e23e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import express from "express"; import { acknowledgeAgentMessage, + getOpportunity, listAgentMessages, + listOpportunities, + markOpportunity, migrate, pool, seedSources @@ -112,6 +115,89 @@ app.post("/messages/:id/ack", requireWebAuth, async (req, res) => { 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(` + +${escapeHtml(opportunity.title)} — Media Intelligence + +
← Opportunities

${escapeHtml(opportunity.title)}

+
${escapeHtml(identity)} · Sign out
+
+
${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.
'} +`); +}); + +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(` + +Opportunities — Media Intelligence + +
← Dashboard

Media Opportunities

Human triage queue. No release drafting or distribution happens here.

+
${escapeHtml(identity)} · Sign out
+
+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.
'} +`); +}); + 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, @@ -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}
-

Media Intelligence MVP

Human-led NZ public-source monitoring behind a dedicated MCP.

+

Media Intelligence MVP

Human-led NZ public-source monitoring behind a dedicated MCP.

Open Opportunities →

${escapeHtml(identity)}
Sign out