220 lines
8.0 KiB
TypeScript

import express from "express";
import {
acknowledgeAgentMessage,
listAgentMessages,
migrate,
pool,
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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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);
}
}
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
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("/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.get("/", requireWebAuth, async (req, res) => {
const sourceRows = 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
ORDER BY s.id`
);
const counts = await pool.query(`
SELECT
(SELECT COUNT(*) FROM signals) AS signals,
(SELECT COUNT(*) FROM opportunities) 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
`);
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></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>
<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><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);
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(
() =>
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);
});